node_maintainer/
maintainer.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Duration;
4
5#[cfg(not(target_arch = "wasm32"))]
6use async_std::fs;
7use nassun::client::{Nassun, NassunOpts};
8use nassun::package::Package;
9use oro_common::CorgiManifest;
10use unicase::UniCase;
11use url::Url;
12
13#[cfg(not(target_arch = "wasm32"))]
14use crate::error::IoContext;
15use crate::error::NodeMaintainerError;
16use crate::graph::{Graph, Node};
17use crate::linkers::Linker;
18#[cfg(not(target_arch = "wasm32"))]
19use crate::linkers::LinkerOptions;
20use crate::resolver::Resolver;
21use crate::{IntoKdl, Lockfile};
22
23pub const DEFAULT_CONCURRENCY: usize = 50;
24pub const DEFAULT_SCRIPT_CONCURRENCY: usize = 6;
25
26#[cfg(not(target_arch = "wasm32"))]
27pub const META_FILE_NAME: &str = ".orogene-meta.kdl";
28#[cfg(not(target_arch = "wasm32"))]
29pub const STORE_DIR_NAME: &str = ".oro-store";
30
31pub type ProgressAdded = Arc<dyn Fn() + Send + Sync>;
32pub type ProgressHandler = Arc<dyn Fn(&Package, Duration) + Send + Sync>;
33pub type PruneProgress = Arc<dyn Fn(&Path) + Send + Sync>;
34pub type ScriptStartHandler = Arc<dyn Fn(&Package, &str) + Send + Sync>;
35pub type ScriptLineHandler = Arc<dyn Fn(&str) + Send + Sync>;
36
37#[derive(Clone)]
38pub struct NodeMaintainerOptions {
39    nassun_opts: NassunOpts,
40    nassun: Option<Nassun>,
41    concurrency: usize,
42    locked: bool,
43    kdl_lock: Option<Lockfile>,
44    npm_lock: Option<Lockfile>,
45
46    #[allow(dead_code)]
47    hoisted: bool,
48    #[allow(dead_code)]
49    script_concurrency: usize,
50    #[allow(dead_code)]
51    cache: Option<PathBuf>,
52    #[allow(dead_code)]
53    prefer_copy: bool,
54    #[allow(dead_code)]
55    validate: bool,
56    #[allow(dead_code)]
57    root: Option<PathBuf>,
58
59    // Intended for progress bars
60    on_resolution_added: Option<ProgressAdded>,
61    on_resolve_progress: Option<ProgressHandler>,
62    #[allow(dead_code)]
63    on_prune_progress: Option<PruneProgress>,
64    #[allow(dead_code)]
65    on_extract_progress: Option<ProgressHandler>,
66    #[allow(dead_code)]
67    on_script_start: Option<ScriptStartHandler>,
68    #[allow(dead_code)]
69    on_script_line: Option<ScriptLineHandler>,
70}
71
72impl NodeMaintainerOptions {
73    /// Create a new builder for NodeMaintainer.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Configure the cache location that NodeMaintainer will use.
79    #[cfg(not(target_arch = "wasm32"))]
80    pub fn cache(mut self, cache: impl AsRef<Path>) -> Self {
81        self.nassun_opts = self.nassun_opts.cache(PathBuf::from(cache.as_ref()));
82        self.cache = Some(PathBuf::from(cache.as_ref()));
83        self
84    }
85
86    /// Controls number of concurrent operations during various apply steps
87    /// (resolution fetches, extractions, etc). Tuning this might help reduce
88    /// memory usage.
89    pub fn concurrency(mut self, concurrency: usize) -> Self {
90        self.concurrency = concurrency;
91        self
92    }
93
94    /// Make the resolver error if the newly-resolved tree would defer from
95    /// an existing lockfile.
96    pub fn locked(mut self, locked: bool) -> Self {
97        self.locked = locked;
98        self
99    }
100
101    /// Controls number of concurrent script executions while running
102    /// `run_script`. This option is separate from `concurrency` because
103    /// executing concurrent scripts is a much heavier operation.
104    pub fn script_concurrency(mut self, concurrency: usize) -> Self {
105        self.script_concurrency = concurrency;
106        self
107    }
108
109    /// Configure the KDL lockfile that NodeMaintainer will use.
110    ///
111    /// If this option is not specified, NodeMaintainer will try to read the
112    /// lockfile from `<root>/package-lock.kdl`.
113    pub fn kdl_lock(mut self, kdl_lock: impl IntoKdl) -> Result<Self, NodeMaintainerError> {
114        let lock = Lockfile::from_kdl(kdl_lock)?;
115        self.kdl_lock = Some(lock);
116        Ok(self)
117    }
118
119    /// Configure the NPM lockfile that NodeMaintainer will use.
120    ///
121    /// If this option is not specified, NodeMaintainer will try to read the
122    /// lockfile from `<root>/package-lock.json`.
123    pub fn npm_lock(mut self, npm_lock: impl AsRef<str>) -> Result<Self, NodeMaintainerError> {
124        let lock = Lockfile::from_npm(npm_lock)?;
125        self.npm_lock = Some(lock);
126        Ok(self)
127    }
128
129    /// Registry used for unscoped packages.
130    ///
131    /// Defaults to https://registry.npmjs.org.
132    pub fn registry(mut self, registry: Url) -> Self {
133        self.nassun_opts = self.nassun_opts.registry(registry);
134        self
135    }
136
137    /// Registry to use for a given `@scope`. That is, what registry to use
138    /// when looking up a package like `@foo/pkg`. This option can be provided
139    /// multiple times.
140    pub fn scope_registry(mut self, scope: impl AsRef<str>, registry: Url) -> Self {
141        self.nassun_opts = self.nassun_opts.scope_registry(scope, registry);
142        self
143    }
144
145    /// Sets basic auth credentials for a registry.
146    pub fn basic_auth(
147        mut self,
148        registry: Url,
149        username: impl AsRef<str>,
150        password: Option<impl AsRef<str>>,
151    ) -> Self {
152        let username = username.as_ref();
153        let password = password.map(|p| p.as_ref().to_string());
154        self.nassun_opts = self.nassun_opts.basic_auth(registry, username, password);
155        self
156    }
157
158    /// Sets bearer token credentials for a registry.
159    pub fn token_auth(mut self, registry: Url, token: impl AsRef<str>) -> Self {
160        self.nassun_opts = self.nassun_opts.token_auth(registry, token.as_ref());
161        self
162    }
163
164    /// Sets the legacy, pre-encoded auth token for a registry.
165    pub fn legacy_auth(mut self, registry: Url, legacy_auth_token: impl AsRef<str>) -> Self {
166        self.nassun_opts = self
167            .nassun_opts
168            .legacy_auth(registry, legacy_auth_token.as_ref());
169        self
170    }
171
172    /// Root directory of the project.
173    #[cfg(not(target_arch = "wasm32"))]
174    pub fn root(mut self, path: impl AsRef<Path>) -> Self {
175        self.nassun_opts = self.nassun_opts.base_dir(path.as_ref());
176        self.root = Some(PathBuf::from(path.as_ref()));
177        self
178    }
179
180    /// Default dist-tag to use when resolving package versions.
181    pub fn default_tag(mut self, tag: impl AsRef<str>) -> Self {
182        self.nassun_opts = self.nassun_opts.default_tag(tag);
183        self
184    }
185
186    /// Provide a pre-configured Nassun instance. Using this option will
187    /// disable all other nassun-related configurations.
188    pub fn nassun(mut self, nassun: Nassun) -> Self {
189        self.nassun = Some(nassun);
190        self
191    }
192
193    /// When extracting packages, prefer to copy files instead of linking
194    /// them.
195    ///
196    /// This option has no effect if hard linking fails (for example, if the
197    /// cache is on a different drive), or if the project is on a filesystem
198    /// that supports Copy-on-Write (zfs, btrfs, APFS (macOS), etc).
199    #[cfg(not(target_arch = "wasm32"))]
200    pub fn prefer_copy(mut self, prefer_copy: bool) -> Self {
201        self.prefer_copy = prefer_copy;
202        self
203    }
204
205    /// Use the hoisted installation mode, where all dependencies and their
206    /// transitive dependencies are installed as high up in the `node_modules`
207    /// tree as possible. This can potentially mean that packages have access
208    /// to dependencies they did not specify in their package.json, but it
209    /// might be useful for compatibility.
210    pub fn hoisted(mut self, hoisted: bool) -> Self {
211        self.hoisted = hoisted;
212        self
213    }
214
215    #[cfg(not(target_arch = "wasm32"))]
216    pub fn proxy(mut self, proxy: bool) -> Self {
217        self.nassun_opts = self.nassun_opts.proxy(proxy);
218        self
219    }
220
221    #[cfg(not(target_arch = "wasm32"))]
222    pub fn proxy_url(mut self, proxy_url: impl AsRef<str>) -> Result<Self, NodeMaintainerError> {
223        self.nassun_opts = self.nassun_opts.proxy_url(proxy_url.as_ref())?;
224        Ok(self)
225    }
226
227    #[cfg(not(target_arch = "wasm32"))]
228    pub fn no_proxy_domain(mut self, no_proxy_domain: impl AsRef<str>) -> Self {
229        self.nassun_opts = self.nassun_opts.no_proxy_domain(no_proxy_domain.as_ref());
230        self
231    }
232
233    pub fn on_resolution_added<F>(mut self, f: F) -> Self
234    where
235        F: Fn() + Send + Sync + 'static,
236    {
237        self.on_resolution_added = Some(Arc::new(f));
238        self
239    }
240
241    pub fn on_resolve_progress<F>(mut self, f: F) -> Self
242    where
243        F: Fn(&Package, Duration) + Send + Sync + 'static,
244    {
245        self.on_resolve_progress = Some(Arc::new(f));
246        self
247    }
248
249    #[cfg(not(target_arch = "wasm32"))]
250    pub fn on_prune_progress<F>(mut self, f: F) -> Self
251    where
252        F: Fn(&Path) + Send + Sync + 'static,
253    {
254        self.on_prune_progress = Some(Arc::new(f));
255        self
256    }
257
258    #[cfg(not(target_arch = "wasm32"))]
259    pub fn on_extract_progress<F>(mut self, f: F) -> Self
260    where
261        F: Fn(&Package, Duration) + Send + Sync + 'static,
262    {
263        self.on_extract_progress = Some(Arc::new(f));
264        self
265    }
266
267    #[cfg(not(target_arch = "wasm32"))]
268    pub fn on_script_start<F>(mut self, f: F) -> Self
269    where
270        F: Fn(&Package, &str) + Send + Sync + 'static,
271    {
272        self.on_script_start = Some(Arc::new(f));
273        self
274    }
275
276    #[cfg(not(target_arch = "wasm32"))]
277    pub fn on_script_line<F>(mut self, f: F) -> Self
278    where
279        F: Fn(&str) + Send + Sync + 'static,
280    {
281        self.on_script_line = Some(Arc::new(f));
282        self
283    }
284
285    async fn get_lockfile(&self) -> Result<Option<Lockfile>, NodeMaintainerError> {
286        if let Some(kdl_lock) = &self.kdl_lock {
287            return Ok(Some(kdl_lock.clone()));
288        }
289        if let Some(npm_lock) = &self.npm_lock {
290            return Ok(Some(npm_lock.clone()));
291        }
292        #[cfg(not(target_arch = "wasm32"))]
293        if let Some(root) = &self.root {
294            let kdl_lock = root.join("package-lock.kdl");
295            if kdl_lock.exists() {
296                match async_std::fs::read_to_string(&kdl_lock)
297                    .await
298                    .io_context(|| format!("Failed to read {}", kdl_lock.display()))
299                    .and_then(Lockfile::from_kdl)
300                {
301                    Ok(lock) => return Ok(Some(lock)),
302                    Err(e) => tracing::debug!("Failed to parse existing package-lock.kdl: {}", e),
303                }
304            }
305            let npm_lock = root.join("package-lock.json");
306            if npm_lock.exists() {
307                match async_std::fs::read_to_string(&npm_lock)
308                    .await
309                    .io_context(|| format!("Failed to read {}", npm_lock.display()))
310                    .and_then(Lockfile::from_npm)
311                {
312                    Ok(lock) => return Ok(Some(lock)),
313                    Err(e) => tracing::debug!("Failed to parse existing package-lock.json: {}", e),
314                }
315            }
316            let npm_lock = root.join("npm-shrinkwrap.json");
317            if npm_lock.exists() {
318                match async_std::fs::read_to_string(&npm_lock)
319                    .await
320                    .io_context(|| format!("Failed to read {}", npm_lock.display()))
321                    .and_then(Lockfile::from_npm)
322                {
323                    Ok(lock) => return Ok(Some(lock)),
324                    Err(e) => {
325                        tracing::debug!("Failed to parse existing npm-shrinkwrap.json: {}", e)
326                    }
327                }
328            }
329        }
330        Ok(None)
331    }
332
333    /// Resolves a [`NodeMaintainer`] using an existing [`CorgiManifest`].
334    pub async fn resolve_manifest(
335        self,
336        root: CorgiManifest,
337    ) -> Result<NodeMaintainer, NodeMaintainerError> {
338        let lockfile = self.get_lockfile().await?;
339        let nassun = self.nassun.unwrap_or_else(|| self.nassun_opts.build());
340        let root_pkg = Nassun::dummy_from_manifest(root.clone());
341        let proj_root = self.root.unwrap_or_else(|| PathBuf::from("."));
342        let mut resolver = Resolver {
343            nassun,
344            graph: Default::default(),
345            concurrency: self.concurrency,
346            locked: self.locked,
347            root: &proj_root,
348            actual_tree: None,
349            on_resolution_added: self.on_resolution_added,
350            on_resolve_progress: self.on_resolve_progress,
351        };
352        let node = resolver.graph.inner.add_node(Node::new(
353            UniCase::new("".to_string()),
354            root_pkg,
355            root,
356            true,
357        )?);
358        resolver.graph[node].root = node;
359        let (graph, _actual_tree) = resolver.run_resolver(lockfile).await?;
360        #[cfg(not(target_arch = "wasm32"))]
361        let linker_opts = LinkerOptions {
362            actual_tree: _actual_tree,
363            concurrency: self.concurrency,
364            script_concurrency: self.script_concurrency,
365            cache: self.cache,
366            prefer_copy: self.prefer_copy,
367            root: proj_root,
368            on_prune_progress: self.on_prune_progress,
369            on_extract_progress: self.on_extract_progress,
370            on_script_start: self.on_script_start,
371            on_script_line: self.on_script_line,
372        };
373        let nm = NodeMaintainer {
374            graph,
375            #[cfg(target_arch = "wasm32")]
376            linker: Linker::null(),
377            #[cfg(not(target_arch = "wasm32"))]
378            linker: if self.hoisted {
379                Linker::hoisted(linker_opts)
380            } else {
381                Linker::isolated(linker_opts)
382            },
383        };
384        #[cfg(debug_assertions)]
385        nm.graph.validate()?;
386        Ok(nm)
387    }
388
389    /// Resolves a [`NodeMaintainer`] using a particular package spec (for
390    /// example, `foo@1.2.3` or `./root`) as its "root" package.
391    pub async fn resolve_spec(
392        self,
393        root_spec: impl AsRef<str>,
394    ) -> Result<NodeMaintainer, NodeMaintainerError> {
395        let lockfile = self.get_lockfile().await?;
396        let nassun = self.nassun_opts.build();
397        let root_pkg = nassun.resolve(root_spec).await?;
398        let proj_root = self.root.unwrap_or_else(|| PathBuf::from("."));
399        let mut resolver = Resolver {
400            nassun,
401            graph: Default::default(),
402            concurrency: self.concurrency,
403            locked: self.locked,
404            root: &proj_root,
405            actual_tree: None,
406            on_resolution_added: self.on_resolution_added,
407            on_resolve_progress: self.on_resolve_progress,
408        };
409        let corgi = root_pkg.corgi_metadata().await?.manifest;
410        let node = resolver.graph.inner.add_node(Node::new(
411            UniCase::new("".to_string()),
412            root_pkg,
413            corgi,
414            true,
415        )?);
416        resolver.graph[node].root = node;
417        let (graph, _actual_tree) = resolver.run_resolver(lockfile).await?;
418        #[cfg(not(target_arch = "wasm32"))]
419        let linker_opts = LinkerOptions {
420            actual_tree: _actual_tree,
421            concurrency: self.concurrency,
422            script_concurrency: self.script_concurrency,
423            cache: self.cache,
424            prefer_copy: self.prefer_copy,
425            root: proj_root,
426            on_prune_progress: self.on_prune_progress,
427            on_extract_progress: self.on_extract_progress,
428            on_script_start: self.on_script_start,
429            on_script_line: self.on_script_line,
430        };
431        let nm = NodeMaintainer {
432            graph,
433            #[cfg(target_arch = "wasm32")]
434            linker: Linker::null(),
435            #[cfg(not(target_arch = "wasm32"))]
436            linker: if self.hoisted {
437                Linker::hoisted(linker_opts)
438            } else {
439                Linker::isolated(linker_opts)
440            },
441        };
442        #[cfg(debug_assertions)]
443        nm.graph.validate()?;
444        Ok(nm)
445    }
446}
447
448impl Default for NodeMaintainerOptions {
449    fn default() -> Self {
450        NodeMaintainerOptions {
451            nassun_opts: Default::default(),
452            nassun: None,
453            concurrency: DEFAULT_CONCURRENCY,
454            kdl_lock: None,
455            npm_lock: None,
456            locked: false,
457            script_concurrency: DEFAULT_SCRIPT_CONCURRENCY,
458            cache: None,
459            hoisted: false,
460            prefer_copy: false,
461            validate: false,
462            root: None,
463            on_resolution_added: None,
464            on_resolve_progress: None,
465            on_prune_progress: None,
466            on_extract_progress: None,
467            on_script_start: None,
468            on_script_line: None,
469        }
470    }
471}
472
473/// Resolves and manages `node_modules` for a given project.
474pub struct NodeMaintainer {
475    pub(crate) graph: Graph,
476    #[allow(dead_code)]
477    linker: Linker,
478}
479
480impl NodeMaintainer {
481    /// Create a new [`NodeMaintainerOptions`] builder to use toconfigure a
482    /// [`NodeMaintainer`].
483    pub fn builder() -> NodeMaintainerOptions {
484        NodeMaintainerOptions::new()
485    }
486
487    /// Resolves a [`NodeMaintainer`] using an existing [`CorgiManifest`].
488    #[cfg(not(target_arch = "wasm32"))]
489    pub async fn resolve_manifest(
490        root: CorgiManifest,
491    ) -> Result<NodeMaintainer, NodeMaintainerError> {
492        Self::builder().resolve_manifest(root).await
493    }
494
495    /// Resolves a [`NodeMaintainer`] using a particular package spec (for
496    /// example, `foo@1.2.3` or `./root`) as its "root" package.
497    #[cfg(not(target_arch = "wasm32"))]
498    pub async fn resolve_spec(
499        root_spec: impl AsRef<str>,
500    ) -> Result<NodeMaintainer, NodeMaintainerError> {
501        Self::builder().resolve_spec(root_spec).await
502    }
503
504    /// Writes the contents of a `package-lock.kdl` file to the file path.
505    #[cfg(not(target_arch = "wasm32"))]
506    pub async fn write_lockfile(&self, path: impl AsRef<Path>) -> Result<(), NodeMaintainerError> {
507        let path = path.as_ref();
508        fs::write(path, self.graph.to_kdl()?.to_string())
509            .await
510            .io_context(|| format!("Failed to write lockfile to {}", path.display()))?;
511        Ok(())
512    }
513
514    /// Returns a [`crate::Lockfile`] representation of the current resolved graph.
515    pub fn to_lockfile(&self) -> Result<crate::Lockfile, NodeMaintainerError> {
516        self.graph.to_lockfile()
517    }
518
519    /// Returns a [`kdl::KdlDocument`] representation of the current resolved graph.
520    pub fn to_kdl(&self) -> Result<kdl::KdlDocument, NodeMaintainerError> {
521        self.graph.to_kdl()
522    }
523
524    /// Returns a [`Package`] for the given package spec, if it is present in
525    /// the dependency tree. The path should be relative to the root of the
526    /// project, and can optionally start with `"node_modules/"`.
527    pub fn package_at_path(&self, path: &Path) -> Option<Package> {
528        self.graph.package_at_path(path)
529    }
530
531    /// Number of unique packages in the dependency tree.
532    pub fn package_count(&self) -> usize {
533        self.graph.inner.node_count()
534    }
535
536    /// Scans the `node_modules` directory and removes any extraneous files or
537    /// directories, including previously-installed packages that are no
538    /// longer valid.
539    #[cfg(not(target_arch = "wasm32"))]
540    pub async fn prune(&self) -> Result<usize, NodeMaintainerError> {
541        self.linker.prune(&self.graph).await
542    }
543
544    /// Extracts the `node_modules/` directory to the project root,
545    /// downloading packages as needed. Whether this method creates files or
546    /// hard links depends on the current filesystem and the `cache` and
547    /// `prefer_copy` options.
548    #[cfg(not(target_arch = "wasm32"))]
549    pub async fn extract(&self) -> Result<usize, NodeMaintainerError> {
550        self.linker.extract(&self.graph).await
551    }
552
553    /// Runs the `preinstall`, `install`, and `postinstall` lifecycle scripts,
554    /// as well as linking the package bins as needed.
555    #[cfg(not(target_arch = "wasm32"))]
556    pub async fn rebuild(&self, ignore_scripts: bool) -> Result<(), NodeMaintainerError> {
557        self.linker.rebuild(&self.graph, ignore_scripts).await
558    }
559}