// Copyright 2018-2026 the Deno authors. MIT license. //! Code for local node_modules resolution. use std::borrow::Cow; use std::cell::RefCell; use std::cmp::Ordering; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::collections::hash_map::Entry; use std::io; use std::path::Path; use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use anyhow::Error as AnyError; use async_trait::async_trait; use deno_error::JsErrorBox; use deno_npm::NpmPackageExtraInfo; use deno_npm::NpmPackageId; use deno_npm::NpmPackageIdPeerDependencies; use deno_npm::NpmResolutionPackage; use deno_npm::NpmResolutionPackageSystemInfo; use deno_npm::NpmSystemInfo; use deno_npm::resolution::NpmResolutionSnapshot; use deno_npm_cache::NpmCache; use deno_npm_cache::NpmCacheHttpClient; use deno_npm_cache::NpmCacheSys; use deno_npm_cache::TarballCache; use deno_npm_cache::create_dir_all_no_symlink; use deno_npm_cache::ensure_not_symlink; use deno_npm_cache::hard_link_file; use deno_path_util::fs::atomic_write_file_with_retries; use deno_resolver::npm::get_package_folder_id_folder_name; use deno_resolver::npm::managed::NpmResolutionCell; use deno_semver::StackString; use deno_semver::package::PackageNv; use deno_terminal::colors; use futures::FutureExt; use futures::StreamExt; use futures::stream::FuturesUnordered; use parking_lot::Mutex; use serde::Deserialize; use serde::Serialize; use sys_traits::FsDirEntry; use sys_traits::FsMetadata; use sys_traits::FsOpen; use sys_traits::FsWrite; use sys_traits::PathsInErrorsExt; use sys_traits::SysWithPathsInErrors; use crate::BinEntries; use crate::CachedNpmPackageExtraInfoProvider; use crate::ExpectedExtraInfo; use crate::LifecycleScriptsConfig; use crate::NpmPackageExtraInfoProvider; use crate::NpmPackageFsInstaller; use crate::PackageCaching; use crate::Reporter; use crate::bin_entries::EntrySetupOutcome; use crate::bin_entries::SetupBinEntrySys; use crate::flag::LaxSingleProcessFsFlag; use crate::flag::LaxSingleProcessFsFlagSys; use crate::fs::CloneDirRecursiveSys; use crate::fs::clone_dir_recursive; use crate::fs::symlink_dir; use crate::lifecycle_scripts::LifecycleScripts; use crate::lifecycle_scripts::LifecycleScriptsExecutor; use crate::lifecycle_scripts::LifecycleScriptsExecutorOptions; use crate::lifecycle_scripts::LifecycleScriptsStrategy; use crate::lifecycle_scripts::has_lifecycle_scripts; use crate::lifecycle_scripts::is_running_lifecycle_script; use crate::package_json::InstallWorkspacePkgDep; use crate::package_json::NpmInstallDepsProvider; use crate::process_state::NpmProcessState; #[sys_traits::auto_impl] pub trait LocalNpmInstallSys: NpmCacheSys + CloneDirRecursiveSys + SetupBinEntrySys + LaxSingleProcessFsFlagSys + sys_traits::EnvVar + sys_traits::FsSymlinkDir + sys_traits::FsCreateJunction + sys_traits::FsRemoveDir { } #[derive(Debug)] pub struct LocalNpmPackageInstallerOptions { pub clean_on_install: bool, pub lifecycle_scripts: Arc, pub node_modules_folder: PathBuf, pub reporter: Option>, pub system_info: NpmSystemInfo, /// Whether `jsr:` dependencies are being installed into `node_modules` via /// JSR's npm compatibility registry (the `jsrDepsInNodeModules` config /// option). Gates the `@scope/name` alias symlinks and the `.npmrc` write /// for `@jsr/*` packages. pub jsr_deps_in_node_modules: bool, } /// Resolver that creates a local node_modules directory /// and resolves packages from it. pub struct LocalNpmPackageInstaller< THttpClient: NpmCacheHttpClient, TReporter: Reporter, TSys: LocalNpmInstallSys, > { lifecycle_scripts_executor: Arc, npm_cache: Arc>, npm_install_deps_provider: Arc, npm_package_extra_info_provider: Arc, reporter: TReporter, resolution: Arc, sys: TSys, tarball_cache: Arc>, clean_on_install: bool, lifecycle_scripts_config: Arc, root_node_modules_path: PathBuf, system_info: NpmSystemInfo, install_reporter: Option>, jsr_deps_in_node_modules: bool, } impl< THttpClient: NpmCacheHttpClient, TReporter: Reporter, TSys: LocalNpmInstallSys, > std::fmt::Debug for LocalNpmPackageInstaller { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LocalNpmPackageInstaller") .field("npm_cache", &self.npm_cache) .field("npm_install_deps_provider", &self.npm_install_deps_provider) .field("reporter", &self.reporter) .field("resolution", &self.resolution) .field("sys", &self.sys) .field("tarball_cache", &self.tarball_cache) .field("clean_on_install", &self.clean_on_install) .field("lifecycle_scripts_config", &self.lifecycle_scripts_config) .field("root_node_modules_path", &self.root_node_modules_path) .field("system_info", &self.system_info) .finish() } } pub(crate) struct InitializingGuard { pub(crate) nv: PackageNv, pub(crate) install_reporter: Arc, } impl Drop for InitializingGuard { fn drop(&mut self) { self.install_reporter.initialized(&self.nv); } } impl< THttpClient: NpmCacheHttpClient, TReporter: Reporter, TSys: LocalNpmInstallSys, > LocalNpmPackageInstaller { #[allow(clippy::too_many_arguments, reason = "construction")] pub fn new( lifecycle_scripts_executor: Arc, npm_cache: Arc>, npm_package_extra_info_provider: Arc, npm_install_deps_provider: Arc, reporter: TReporter, resolution: Arc, sys: TSys, tarball_cache: Arc>, options: LocalNpmPackageInstallerOptions, ) -> Self { Self { lifecycle_scripts_executor, npm_cache, npm_install_deps_provider, npm_package_extra_info_provider, reporter, resolution, tarball_cache, sys, clean_on_install: options.clean_on_install, lifecycle_scripts_config: options.lifecycle_scripts, root_node_modules_path: options.node_modules_folder, install_reporter: options.reporter, system_info: options.system_info, jsr_deps_in_node_modules: options.jsr_deps_in_node_modules, } } async fn sync_resolution_with_fs( &self, snapshot: &NpmResolutionSnapshot, ) -> Result<(), SyncResolutionWithFsError> { let has_no_packages = snapshot.is_empty() && self.npm_install_deps_provider.local_pkgs().is_empty() && !self .npm_install_deps_provider .workspace_pkgs() .iter() .any(|pkg| !pkg.scripts.is_empty()); let deno_local_registry_dir = self.root_node_modules_path.join(".deno"); if has_no_packages && (!self.clean_on_install || !self.sys.fs_exists_no_err(&deno_local_registry_dir)) { return Ok(()); // don't create the directory } // don't set up node_modules (and more importantly try to acquire the file lock) // if we're running as part of a lifecycle script if is_running_lifecycle_script(&self.sys) { return Ok(()); } let sys = self.sys.with_paths_in_errors(); let deno_node_modules_dir = deno_local_registry_dir.join("node_modules"); sys.fs_create_dir_all(&deno_node_modules_dir)?; let bin_node_modules_dir_path = self.root_node_modules_path.join(".bin"); let single_process_lock = LaxSingleProcessFsFlag::lock( sys.as_ref(), deno_local_registry_dir.join(".deno.lock"), &self.reporter, // similar message used by cargo build "waiting for file lock on node_modules directory", ) .await; let package_partitions = snapshot.all_system_packages_partitioned(&self.system_info); let pb_clear_guard = self.reporter.clear_guard(); // prevent flickering // load this after we get the directory lock let mut setup_cache = LocalSetupCache::load( sys.as_ref().clone(), deno_local_registry_dir.join(".setup-cache.bin"), ); // 1. Check if packages changed and clean up if needed if self.clean_on_install { let root_folder_names = root_package_folder_names(snapshot, &self.npm_install_deps_provider); let packages_hash = calculate_packages_hash(&package_partitions, &root_folder_names); if setup_cache.packages_changed(packages_hash) { cleanup_unused_packages( sys.as_ref(), &self.root_node_modules_path, &deno_local_registry_dir, &package_partitions, &root_folder_names, &mut setup_cache, ); } setup_cache.set_clean_packages_hash(packages_hash); } // 2. Write all the packages out the .deno directory. // // Copy (hardlink in future) // to // node_modules/.deno//node_modules/ let workspace_lifecycle_packages = self.resolve_workspace_lifecycle_packages(snapshot)?; let mut cache_futures = FuturesUnordered::new(); let mut newest_packages_by_name: HashMap< &StackString, &NpmResolutionPackage, > = HashMap::with_capacity(package_partitions.packages.len()); let bin_entries = Rc::new(RefCell::new(BinEntries::new(sys))); let lifecycle_scripts = Rc::new(RefCell::new(LifecycleScripts::new( sys.as_ref(), &self.lifecycle_scripts_config, LocalLifecycleScripts { sys: sys.as_ref(), deno_local_registry_dir: &deno_local_registry_dir, install_reporter: self.install_reporter.clone(), }, ))); let packages_with_deprecation_warnings = Arc::new(Mutex::new(Vec::new())); let mut package_tags: HashMap<&PackageNv, BTreeSet<&str>> = HashMap::new(); for (package_req, package_nv) in snapshot.package_reqs() { if let Some(tag) = package_req.version_req.tag() { package_tags.entry(package_nv).or_default().insert(tag); } } let extra_info_provider = Arc::new(CachedNpmPackageExtraInfoProvider::new( self.npm_package_extra_info_provider.clone(), )); // Map a package to the workspace member directory that declares it as a // direct dependency, so its lifecycle scripts run with `INIT_CWD` pointing // at that member rather than the workspace root. let lifecycle_script_init_cwds: Rc>> = Rc::new(crate::lifecycle_scripts::member_dep_init_cwds( &self.npm_install_deps_provider, snapshot, self.root_node_modules_path.parent(), )); for package in &package_partitions.packages { if let Some(current_pkg) = newest_packages_by_name.get_mut(&package.id.nv.name) { if current_pkg.id.nv.cmp(&package.id.nv) == Ordering::Less { *current_pkg = package; } } else { newest_packages_by_name.insert(&package.id.nv.name, package); }; let package_folder_name = get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), ); let folder_path = deno_local_registry_dir.join(&package_folder_name); let tags = package_tags .get(&package.id.nv) .map(|tags| { capacity_builder::StringBuilder::::build(|builder| { for (i, tag) in tags.iter().enumerate() { if i > 0 { builder.append(',') } builder.append(*tag); } }) .unwrap() }) .unwrap_or_default(); enum PackageFolderState { UpToDate, Uninitialized, TagsOutdated, } let initialized_file = folder_path.join(".initialized"); let package_state = if tags.is_empty() { if sys.fs_exists_no_err(&initialized_file) { PackageFolderState::UpToDate } else { PackageFolderState::Uninitialized } } else { self .sys .fs_read_to_string(&initialized_file) .map(|s| { if s != tags { PackageFolderState::TagsOutdated } else { PackageFolderState::UpToDate } }) .unwrap_or(PackageFolderState::Uninitialized) }; if !self .npm_cache .cache_setting() .should_use_for_npm_package(&package.id.nv.name) || matches!(package_state, PackageFolderState::Uninitialized) { if let Some(dist) = &package.dist { // cache bust the dep from the dep setup cache so the symlinks // are forced to be recreated setup_cache.remove_dep(&package_folder_name); let folder_path = folder_path.clone(); let packages_with_deprecation_warnings = packages_with_deprecation_warnings.clone(); let extra_info_provider = extra_info_provider.clone(); let lifecycle_scripts = lifecycle_scripts.clone(); let bin_entries_to_setup = bin_entries.clone(); let install_reporter = self.install_reporter.clone(); let lifecycle_script_init_cwds = lifecycle_script_init_cwds.clone(); cache_futures.push( async move { self .tarball_cache .ensure_package(&package.id.nv, dist) .await .map_err(JsErrorBox::from_err)?; let pb_guard = self.reporter.on_initializing(&package.id.nv.to_string()); let _initialization_guard = install_reporter.as_ref().map(|install_reporter| { install_reporter.initializing(&package.id.nv); InitializingGuard { nv: package.id.nv.clone(), install_reporter: install_reporter.clone(), } }); let sub_node_modules = folder_path.join("node_modules"); let package_path = join_package_name( Cow::Owned(sub_node_modules), &package.id.nv.name, ); let cache_folder = self.npm_cache.package_folder_for_nv(&package.id.nv); let handle = crate::rt::spawn_blocking({ let package_path = package_path.clone(); let sys = self.sys.clone(); move || { clone_dir_recursive(&sys, &cache_folder, &package_path)?; // write out a file that indicates this folder has been initialized write_initialized_file(&sys, &initialized_file, &tags)?; Ok::<_, SyncResolutionWithFsError>(()) } }); let needs_extra_from_disk = package.extra.is_none() // When using abbreviated packument format, has_scripts may // be true (from hasInstallScript) while extra.scripts is // empty. In that case, read from disk to get real scripts. || (package.has_scripts && package .extra .as_ref() .is_some_and(|e| e.scripts.is_empty())); let extra = if (package.has_bin || package.has_scripts || package.is_deprecated) && needs_extra_from_disk { // Wait for extraction to complete first, since // get_package_extra_info may read from the on-disk // package.json which doesn't exist until extraction finishes. handle .await .map_err(JsErrorBox::from_err)? .map_err(JsErrorBox::from_err)?; extra_info_provider .get_package_extra_info( &package.id.nv, &package_path, ExpectedExtraInfo::from_package(package), ) .await .map_err(JsErrorBox::from_err)? } else { let (result, extra) = futures::future::join( handle, std::future::ready(Ok::<_, JsErrorBox>( package.extra.clone().unwrap_or_default(), )), ) .await; result .map_err(JsErrorBox::from_err)? .map_err(JsErrorBox::from_err)?; extra? }; if package.has_bin { bin_entries_to_setup.borrow_mut().add( package, &extra, package_path.to_path_buf(), ); } if package.has_scripts { let init_cwds = lifecycle_script_init_cwds .get(&package.id) .cloned() .unwrap_or_default(); lifecycle_scripts.borrow_mut().add( package, &extra, package_path.into(), init_cwds, ); } if package.is_deprecated && let Some(deprecated) = &extra.deprecated { packages_with_deprecation_warnings .lock() .push((package.id.nv.clone(), deprecated.clone())); } // finally stop showing the progress bar drop(pb_guard); // explicit for clarity Ok::<_, JsErrorBox>(()) } .boxed_local(), ); } } else { if matches!(package_state, PackageFolderState::TagsOutdated) { write_initialized_file(sys.as_ref(), &initialized_file, &tags)?; } if package.has_bin || package.has_scripts { let bin_entries_to_setup = bin_entries.clone(); let lifecycle_scripts = lifecycle_scripts.clone(); let extra_info_provider = extra_info_provider.clone(); let lifecycle_script_init_cwds = lifecycle_script_init_cwds.clone(); let sub_node_modules = folder_path.join("node_modules"); let package_path = join_package_name( Cow::Owned(sub_node_modules), &package.id.nv.name, ); cache_futures.push( async move { let extra = extra_info_provider .get_package_extra_info( &package.id.nv, &package_path, ExpectedExtraInfo::from_package(package), ) .await .map_err(JsErrorBox::from_err)?; if package.has_bin { bin_entries_to_setup.borrow_mut().add( package, &extra, package_path.to_path_buf(), ); } if package.has_scripts { let init_cwds = lifecycle_script_init_cwds .get(&package.id) .cloned() .unwrap_or_default(); lifecycle_scripts.borrow_mut().add( package, &extra, package_path.into(), init_cwds, ); } Ok(()) } .boxed_local(), ); } } } // Wait for all npm package installations to complete before applying patches // This prevents race conditions where npm packages could overwrite patch files while let Some(result) = cache_futures.next().await { result?; // surface the first error } // 3. Setup the patch packages for patch_pkg in self.npm_install_deps_provider.patch_pkgs() { // there might be multiple ids per package due to peer dep copy packages for id in snapshot.package_ids_for_nv(&patch_pkg.nv) { let package = snapshot.package_from_id(id).unwrap(); let package_folder_name = get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), ); // node_modules/.deno//node_modules/ -> local package folder let target = join_package_name( Cow::Owned( deno_local_registry_dir .join(&package_folder_name) .join("node_modules"), ), &patch_pkg.nv.name, ); cache_futures.push( async move { let from_path = patch_pkg.target_dir.clone(); let sys = self.sys.clone(); crate::rt::spawn_blocking({ move || { clone_dir_recursive_except_node_modules_child( &sys, &from_path, &target, ) } }) .await .map_err(JsErrorBox::from_err)? .map_err(JsErrorBox::from_err)?; Ok::<_, JsErrorBox>(()) } .boxed_local(), ); } } // copy packages copy from the main packages, so wait // until these are all done while let Some(result) = cache_futures.next().await { result?; // surface the first error } // 4. Create any "copy" packages, which are used for peer dependencies for package in &package_partitions.copy_packages { let package_cache_folder_id = package.get_package_cache_folder_id(); let destination_path = deno_local_registry_dir .join(get_package_folder_id_folder_name(&package_cache_folder_id)); let initialized_file = destination_path.join(".initialized"); if !sys.fs_exists_no_err(&initialized_file) { let sub_node_modules = destination_path.join("node_modules"); let package_path = join_package_name(Cow::Owned(sub_node_modules), &package.id.nv.name); let source_path = join_package_name( Cow::Owned( deno_local_registry_dir .join(get_package_folder_id_folder_name( &package_cache_folder_id.with_no_count(), )) .join("node_modules"), ), &package.id.nv.name, ); cache_futures.push( async move { let sys = self.sys.clone(); crate::rt::spawn_blocking(move || { clone_dir_recursive(&sys, &source_path, &package_path) .map_err(JsErrorBox::from_err)?; // write out a file that indicates this folder has been initialized create_initialized_file(&sys, &initialized_file) .map_err(JsErrorBox::from_err)?; Ok::<_, JsErrorBox>(()) }) .await .map_err(JsErrorBox::from_err)? .map_err(JsErrorBox::from_err)?; Ok::<_, JsErrorBox>(()) } .boxed_local(), ); } } while let Some(result) = cache_futures.next().await { result?; // surface the first error } drop(cache_futures); // 5. Symlink all the dependencies into the .deno directory. // // Symlink node_modules/.deno//node_modules/ to // node_modules/.deno//node_modules/ for package in package_partitions.iter_all() { let package_folder_name = get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), ); let sub_node_modules = deno_local_registry_dir .join(&package_folder_name) .join("node_modules"); let mut dep_setup_cache = setup_cache.with_dep(&package_folder_name); for (name, dep_id) in &package.dependencies { let dep = snapshot.package_from_id(dep_id).unwrap(); if package.optional_dependencies.contains(name) && !dep.system.matches_system(&self.system_info) { continue; // this isn't a dependency for the current system } let dep_cache_folder_id = dep.get_package_cache_folder_id(); let dep_folder_name = get_package_folder_id_folder_name(&dep_cache_folder_id); if package.dist.is_none() || dep_setup_cache.insert(name, &dep_folder_name) { let dep_folder_path = join_package_name( Cow::Owned( deno_local_registry_dir .join(dep_folder_name) .join("node_modules"), ), &dep_id.nv.name, ); symlink_package_dir( sys.as_ref(), &dep_folder_path, &sub_node_modules, name, )?; } } } let mut found_names: HashMap<&StackString, &PackageNv> = HashMap::new(); // set of node_modules in workspace packages that we've already ensured exist let mut existing_child_node_modules_dirs: HashSet = HashSet::new(); // 6. Create symlinks for package json dependencies { for remote in self.npm_install_deps_provider.remote_pkgs() { let remote_pkg = match snapshot.resolve_pkg_from_pkg_req(&remote.req) { Ok(remote_pkg) => remote_pkg, _ => { if remote.req.version_req.tag().is_some() { // couldn't find a match, and `resolve_best_package_id` // panics if you give it a tag continue; } else { match snapshot.resolve_best_package_id( &remote.req.name, &remote.req.version_req, ) { Some(remote_id) => { snapshot.package_from_id(&remote_id).unwrap() } _ => { continue; // skip, package not found } } } } }; let Some(remote_alias) = &remote.alias else { continue; }; let alias_clashes = remote.req.name != *remote_alias && newest_packages_by_name.contains_key(remote_alias); let install_in_child = { // we'll install in the child if the alias is taken by another package, or // if there's already a package with the same name but different version // linked into the root match found_names.entry(remote_alias) { Entry::Occupied(nv) => { // alias to a different package (in case of duplicate aliases) // or the version doesn't match the version in the root node_modules alias_clashes || &remote_pkg.id.nv != *nv.get() } Entry::Vacant(entry) => { entry.insert(&remote_pkg.id.nv); alias_clashes } } }; let target_folder_name = get_package_folder_id_folder_name( &remote_pkg.get_package_cache_folder_id(), ); let local_registry_package_path = join_package_name( Cow::Owned( deno_local_registry_dir .join(&target_folder_name) .join("node_modules"), ), &remote_pkg.id.nv.name, ); if install_in_child { // symlink the dep into the package's child node_modules folder let dest_node_modules = remote.base_dir.join("node_modules"); if !existing_child_node_modules_dirs.contains(&dest_node_modules) { sys.fs_create_dir_all(&dest_node_modules)?; existing_child_node_modules_dirs.insert(dest_node_modules.clone()); } symlink_package_dir( sys.as_ref(), &local_registry_package_path, &dest_node_modules, remote_alias, )?; } else { // symlink the package into `node_modules/` if setup_cache.insert_root_symlink(remote_alias, &target_folder_name) { symlink_package_dir( sys.as_ref(), &local_registry_package_path, &self.root_node_modules_path, remote_alias, )?; } } } } // 7. Create symlinks for the remaining top level packages in the node_modules folder. // (These may be present if they are not in the package.json dependencies) // Symlink node_modules/.deno//node_modules/ to // node_modules/ let mut ids = snapshot .top_level_packages() .filter(|f| !found_names.contains_key(&f.nv.name)) .collect::>(); ids.sort_by(|a, b| b.cmp(a)); // create determinism and only include the latest version for id in ids { match found_names.entry(&id.nv.name) { Entry::Occupied(_) => { continue; // skip, already handled } Entry::Vacant(entry) => { entry.insert(&id.nv); } } let package = snapshot.package_from_id(id).unwrap(); let target_folder_name = get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), ); if setup_cache.insert_root_symlink(&id.nv.name, &target_folder_name) { let local_registry_package_path = join_package_name( Cow::Owned( deno_local_registry_dir .join(&target_folder_name) .join("node_modules"), ), &id.nv.name, ); symlink_package_dir( sys.as_ref(), &local_registry_package_path, &self.root_node_modules_path, &id.nv.name, )?; } // Additionally expose JSR npm-compat packages (`@jsr/scope__name`) under // their original `@scope/name` so external tooling resolves them like a // regular npm install (mirrors pnpm's `jsr:` alias symlink). This only // covers top-level packages; transitive `jsr:` deps resolve through their // `@jsr/...` name and don't need a root alias. The alias is skipped when a // real package already claims that root name, so we don't clobber it. // Only done when `jsrDepsInNodeModules` is enabled so projects that depend // on `npm:@jsr/...` packages directly are left untouched. if self.jsr_deps_in_node_modules && let Some(alias) = jsr_npm_name_to_original(&id.nv.name) && !newest_packages_by_name .contains_key(&StackString::from(alias.as_str())) && setup_cache.insert_root_symlink(&alias, &target_folder_name) { let local_registry_package_path = join_package_name( Cow::Owned( deno_local_registry_dir .join(&target_folder_name) .join("node_modules"), ), &id.nv.name, ); symlink_package_dir( sys.as_ref(), &local_registry_package_path, &self.root_node_modules_path, &alias, )?; } } // When `jsrDepsInNodeModules` is enabled and JSR packages were materialized // into `node_modules` (under their `@jsr/scope__name` npm-compat name), make // sure a `.npmrc` next to `node_modules` points the `@jsr` scope at JSR's // npm registry so external tooling (npm, pnpm, yarn) can resolve them too. // Gated on the config so we don't write into the project directory of users // that merely depend on `npm:@jsr/...` packages directly. if self.jsr_deps_in_node_modules && let Some(project_dir) = self.root_node_modules_path.parent() && package_partitions .packages .iter() .any(|package| package.id.nv.name.starts_with("@jsr/")) { ensure_jsr_npmrc(sys.as_ref(), project_dir)?; } // 8. Create a node_modules/.deno/node_modules/ directory with // the remaining packages for package in newest_packages_by_name.values() { match found_names.entry(&package.id.nv.name) { Entry::Occupied(_) => { continue; // skip, already handled } Entry::Vacant(entry) => { entry.insert(&package.id.nv); } } let target_folder_name = get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), ); if setup_cache .insert_deno_symlink(&package.id.nv.name, &target_folder_name) { let local_registry_package_path = join_package_name( Cow::Owned( deno_local_registry_dir .join(target_folder_name) .join("node_modules"), ), &package.id.nv.name, ); symlink_package_dir( sys.as_ref(), &local_registry_package_path, &deno_node_modules_dir, &package.id.nv.name, )?; } } // 9. Set up `node_modules/.bin` entries for packages that need it. { let bin_entries = match Rc::try_unwrap(bin_entries) { Ok(bin_entries) => bin_entries.into_inner(), Err(_) => panic!("Should have sole ref to rc."), }; bin_entries.finish( snapshot, &bin_node_modules_dir_path, |setup_outcome| { let lifecycle_scripts = lifecycle_scripts.borrow(); match setup_outcome { EntrySetupOutcome::MissingEntrypoint { package, package_path, extra, .. } if has_lifecycle_scripts(sys.as_ref(), extra, package_path) && lifecycle_scripts.can_run_scripts(&package.id.nv) && !lifecycle_scripts.has_run_scripts(package) => { // ignore, it might get fixed when the lifecycle scripts run. // if not, we'll warn then } outcome => outcome.warn_if_failed(), } }, )?; } // 10. Create symlinks for the workspace packages { // todo(dsherret): this is not exactly correct because it should // install correctly for a workspace (potentially in sub directories), // but this is good enough for a first pass for pkg in self.npm_install_deps_provider.local_pkgs() { let Some(pkg_alias) = &pkg.alias else { continue; }; symlink_package_dir( sys.as_ref(), &pkg.target_dir, &self.root_node_modules_path, pkg_alias, )?; } } // 11. Create a `node_modules` directory inside each workspace member and // symlink that member's direct dependencies into it. This mirrors how npm // and pnpm lay out workspaces so that native Node.js tooling (for example // `svelte-check`, `astro`, or `eslint` plugins) run from within a member // resolves the member's dependencies and sibling workspace members the same // way it would in a Node.js project. Without this, members share only the // workspace root's `node_modules`, which both hides missing dependencies // (shadow dependencies) and breaks tools that expect a local `node_modules`. { let workspace_member_dirs: HashMap<&PackageNv, &Path> = self .npm_install_deps_provider .workspace_pkgs() .iter() .map(|pkg| (&pkg.nv, pkg.target_dir.as_path())) .collect(); for workspace_pkg in self.npm_install_deps_provider.workspace_pkgs() { // The workspace root's `node_modules` is already fully set up above. // (Comparing paths here is unreliable: `root_node_modules_path` is // canonicalized while `target_dir` is not, so on Windows they can // differ by 8.3 short names or casing for the same directory.) if workspace_pkg.is_root { continue; } let member_node_modules = workspace_pkg.target_dir.join("node_modules"); // Remove links to dependencies the member no longer declares (or to // sibling members that were removed) so they stop being resolvable, // mirroring how the root `node_modules` prunes stale links above. This // also covers a member that dropped all of its dependencies, which is // why it runs before the `deps.is_empty()` short-circuit. let keep_aliases: HashSet<&str> = workspace_pkg .deps .iter() .map(|dep| dep.alias().as_str()) .collect(); remove_stale_member_symlinks( sys.as_ref(), &member_node_modules, &keep_aliases, ); // The member's direct npm dependencies that ship executables, gathered // while linking so their bins can be set up in the member's `.bin` once // every alias link exists. let mut bin_deps: Vec = Vec::new(); if !workspace_pkg.deps.is_empty() { let member_key = workspace_pkg.target_dir.to_string_lossy(); let mut dep_cache = setup_cache.with_dep(&member_key); let mut created_dir = false; for dep in &workspace_pkg.deps { let (alias, target_path, cache_target) = match dep { InstallWorkspacePkgDep::Remote { alias, req } => { let Some(id) = resolve_remote_pkg_id(snapshot, req) else { continue; }; let package = snapshot.package_from_id(&id).unwrap(); let target_folder_name = get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), ); let local_registry_package_path = join_package_name( Cow::Owned( deno_local_registry_dir .join(&target_folder_name) .join("node_modules"), ), &package.id.nv.name, ); if package.has_bin { // Collected before the dep-cache short-circuit below so a // dependency whose link is already current still contributes // its bins (the alias link exists either way). bin_deps.push(MemberBinDep { package, read_path: local_registry_package_path.clone(), link_path: member_node_modules.join(alias.as_str()), }); } (alias, local_registry_package_path, target_folder_name) } InstallWorkspacePkgDep::Workspace { alias, nv } => { let Some(target_dir) = workspace_member_dirs.get(nv) else { continue; }; (alias, target_dir.to_path_buf(), nv.to_string()) } }; if !dep_cache.insert(alias, &cache_target) { continue; } if !created_dir { sys.fs_create_dir_all(&member_node_modules)?; created_dir = true; } symlink_package_dir( sys.as_ref(), &target_path, &member_node_modules, alias.as_str(), )?; } } // Populate (and prune) the member's `node_modules/.bin`. Runs even when // the member has no dependencies so a dropped last bin is cleaned up. setup_member_bin_entries( sys, snapshot, &extra_info_provider, &member_node_modules, &bin_deps, ) .await?; } } for package in &workspace_lifecycle_packages { sys.fs_create_dir_all(local_node_modules_package_folder( &deno_local_registry_dir, &package.package, ))?; lifecycle_scripts.borrow_mut().add( &package.package, &NpmPackageExtraInfo { scripts: package.scripts.clone(), ..Default::default() }, Cow::Borrowed(&package.package_path), Vec::new(), ); } { let packages_with_deprecation_warnings = packages_with_deprecation_warnings.lock(); if !packages_with_deprecation_warnings.is_empty() { use std::fmt::Write; let mut output = String::new(); let _ = writeln!( &mut output, "{} {}", colors::yellow("╭"), colors::yellow_bold("Warning") ); let _ = writeln!(&mut output, "{}", colors::yellow("│")); let _ = writeln!( &mut output, "{} The following packages are deprecated:", colors::yellow("│"), ); for (package_nv, msg) in packages_with_deprecation_warnings.iter() { let _ = writeln!( &mut output, "{} {}", colors::yellow("│"), colors::gray(format!("npm:{:?} ({})", package_nv, msg)) ); } let _ = write!(&mut output, "{}", colors::yellow("╰─")); if let Some(install_reporter) = &self.install_reporter { install_reporter.deprecated_message(output); } else { log::warn!("{}", output); } } } let lifecycle_scripts_to_run = std::mem::replace( &mut *lifecycle_scripts.borrow_mut(), LifecycleScripts::new( sys.as_ref(), &self.lifecycle_scripts_config, LocalLifecycleScripts { sys: sys.as_ref(), deno_local_registry_dir: &deno_local_registry_dir, install_reporter: self.install_reporter.clone(), }, ), ); drop(lifecycle_scripts); lifecycle_scripts_to_run.warn_not_run_scripts()?; let packages_with_scripts = lifecycle_scripts_to_run.packages_with_scripts(); if !packages_with_scripts.is_empty() { let additional_packages = workspace_lifecycle_packages .iter() .map(|package| &package.package) .collect::>(); let process_state = NpmProcessState::new_local( snapshot.as_valid_serialized(), &self.root_node_modules_path, crate::process_state::NpmProcessStateLinkerMode::Isolated, ) .as_serialized(); self .lifecycle_scripts_executor .execute(LifecycleScriptsExecutorOptions { init_cwd: &self.lifecycle_scripts_config.initial_cwd, process_state: process_state.as_str(), root_node_modules_dir_path: &self.root_node_modules_path, resolve_pkg_folder: None, on_ran_pkg_scripts: &|pkg| { create_initialized_file( sys.as_ref(), &ran_scripts_file(&deno_local_registry_dir, pkg), ) .map_err(JsErrorBox::from_err) }, snapshot, system_packages: &package_partitions.packages, additional_packages: &additional_packages, packages_with_scripts, extra_info_provider: &extra_info_provider, }) .await .map_err(SyncResolutionWithFsError::LifecycleScripts)? } setup_cache.save(); drop(single_process_lock); drop(pb_clear_guard); Ok(()) } fn resolve_workspace_lifecycle_packages( &self, snapshot: &NpmResolutionSnapshot, ) -> Result, JsErrorBox> { let workspace_pkgs = self.npm_install_deps_provider.workspace_pkgs(); let workspace_pkg_ids = workspace_pkgs .iter() .map(|pkg| { ( pkg.nv.clone(), NpmPackageId { nv: pkg.nv.clone(), peer_dependencies: NpmPackageIdPeerDependencies::from([]), }, ) }) .collect::>(); let mut packages = Vec::with_capacity(workspace_pkgs.len()); for workspace_pkg in workspace_pkgs { let mut dependencies = HashMap::with_capacity(workspace_pkg.deps.len()); for dep in &workspace_pkg.deps { match dep { InstallWorkspacePkgDep::Remote { alias, req } => { if let Some(id) = resolve_remote_pkg_id(snapshot, req) { dependencies.insert(alias.clone(), id); } } InstallWorkspacePkgDep::Workspace { alias, nv } => { if let Some(id) = workspace_pkg_ids.get(nv) { dependencies.insert(alias.clone(), id.clone()); } } } } let id = workspace_pkg_ids .get(&workspace_pkg.nv) .expect("workspace package id should exist") .clone(); packages.push(WorkspaceLifecyclePackage { package: NpmResolutionPackage { id, copy_index: 0, system: NpmResolutionPackageSystemInfo::default(), dist: None, dependencies, optional_dependencies: Default::default(), optional_peer_dependencies: Default::default(), extra: Some(NpmPackageExtraInfo { scripts: workspace_pkg.scripts.clone(), ..Default::default() }), is_deprecated: false, has_bin: false, has_scripts: !workspace_pkg.scripts.is_empty(), }, package_path: workspace_pkg.target_dir.clone(), scripts: workspace_pkg.scripts.clone(), }); } Ok(packages) } } struct WorkspaceLifecyclePackage { package: NpmResolutionPackage, package_path: PathBuf, scripts: HashMap, } fn resolve_remote_pkg_id( snapshot: &NpmResolutionSnapshot, req: &deno_semver::package::PackageReq, ) -> Option { match snapshot.resolve_pkg_from_pkg_req(req) { Ok(pkg) => Some(pkg.id.clone()), Err(_) if req.version_req.tag().is_some() => None, Err(_) => snapshot.resolve_best_package_id(&req.name, &req.version_req), } } #[async_trait(?Send)] impl< THttpClient: NpmCacheHttpClient, TReporter: Reporter, TSys: LocalNpmInstallSys, > NpmPackageFsInstaller for LocalNpmPackageInstaller { async fn cache_packages<'a>( &self, caching: PackageCaching<'a>, ) -> Result<(), JsErrorBox> { let snapshot = match caching { PackageCaching::All => self.resolution.snapshot(), PackageCaching::Only(reqs) => self.resolution.subset(&reqs), }; self .sync_resolution_with_fs(&snapshot) .await .map_err(JsErrorBox::from_err) } } #[derive(Debug, thiserror::Error, deno_error::JsError)] pub enum SyncResolutionWithFsError { #[class(generic)] #[error(transparent)] LifecycleScripts(AnyError), #[class(inherit)] #[error(transparent)] Io(#[from] std::io::Error), #[class(inherit)] #[error(transparent)] Other(#[from] JsErrorBox), } pub(crate) fn clone_dir_recursive_except_node_modules_child( sys: &impl CloneDirRecursiveSys, from: &Path, to: &Path, ) -> Result<(), std::io::Error> { let sys = sys.with_paths_in_errors(); ensure_not_symlink(sys.as_ref(), to)?; _ = sys.fs_remove_dir_all(to); create_dir_all_no_symlink(sys.as_ref(), to)?; for entry in sys.fs_read_dir(from)? { let entry = entry?; if entry.file_name().to_str() == Some("node_modules") { continue; // ignore } let file_type = entry.file_type()?; let new_from = from.join(entry.file_name()); let new_to = to.join(entry.file_name()); if file_type.is_dir() { clone_dir_recursive_except_node_modules_child( sys.as_ref(), &new_from, &new_to, )?; } else if file_type.is_file() { hard_link_file(sys.as_ref(), &new_from, &new_to).or_else(|_| { // Remove any existing file first so the copy writes a new inode // rather than writing through a hardlinked destination, which // would corrupt the file at its other paths. Removing first also // breaks hardlinks to currently-executing binaries (ETXTBSY). let _ = sys.fs_remove_file(&new_to); sys.fs_copy(&new_from, &new_to).map(|_| ()) })?; } } Ok(()) } /// Reverse of JSR's npm-compatibility naming: `@jsr/scope__name` -> the /// original JSR specifier name `@scope/name`. Returns `None` when `name` is not /// a `@jsr/`-scoped package. Used to expose JSR packages installed via the npm /// registry under their original names so external tooling (type checkers, /// bundlers) can resolve them like pnpm/npm do. fn jsr_npm_name_to_original(name: &str) -> Option { let rest = name.strip_prefix("@jsr/")?; let (scope, pkg) = rest.split_once("__")?; Some(format!("@{scope}/{pkg}")) } /// Ensures the `.npmrc` next to the `node_modules` directory declares JSR's npm /// compatibility registry (`@jsr:registry=https://npm.jsr.io`). This lets /// external tooling (npm, pnpm, yarn) resolve the `@jsr/*` packages Deno /// materialized into `node_modules`. A pre-existing `@jsr:registry` entry is /// respected (left untouched); otherwise the line is appended to an existing /// `.npmrc` or a new one is created. fn ensure_jsr_npmrc( sys: &TSys, project_dir: &Path, ) -> Result<(), std::io::Error> { const JSR_REGISTRY_LINE: &str = "@jsr:registry=https://npm.jsr.io"; const NPMRC_PERM: u32 = 0o644; let npmrc_path = project_dir.join(".npmrc"); let existing = match sys.fs_read_to_string(&npmrc_path) { Ok(contents) => Some(contents.into_owned()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, Err(err) => return Err(err), }; if let Some(existing) = &existing && existing .lines() .any(|line| line.trim_start().starts_with("@jsr:registry")) { // Respect an existing `@jsr` registry configuration. return Ok(()); } let mut contents = existing.unwrap_or_default(); if !contents.is_empty() && !contents.ends_with('\n') { contents.push('\n'); } contents.push_str(JSR_REGISTRY_LINE); contents.push('\n'); atomic_write_file_with_retries( sys, &npmrc_path, contents.as_bytes(), NPMRC_PERM, ) } /// `node_modules/.deno//` fn local_node_modules_package_folder( local_registry_dir: &Path, package: &NpmResolutionPackage, ) -> PathBuf { local_registry_dir.join(get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), )) } /// `node_modules/.deno//.scripts-run` fn ran_scripts_file( local_registry_dir: &Path, package: &NpmResolutionPackage, ) -> PathBuf { local_node_modules_package_folder(local_registry_dir, package) .join(".scripts-run") } struct LocalLifecycleScripts<'a, TSys: FsOpen + FsMetadata> { sys: &'a TSys, deno_local_registry_dir: &'a Path, install_reporter: Option>, } impl LocalLifecycleScripts<'_, TSys> { /// `node_modules/.deno//.scripts-warned` fn warned_scripts_file(&self, package: &NpmResolutionPackage) -> PathBuf { local_node_modules_package_folder(self.deno_local_registry_dir, package) .join(".scripts-warned") } } impl LifecycleScriptsStrategy for LocalLifecycleScripts<'_, TSys> { fn warn_on_scripts_not_run( &self, packages: &[(&NpmResolutionPackage, std::path::PathBuf)], ) -> Result<(), std::io::Error> { use std::fmt::Write; let mut output = String::new(); if !packages.is_empty() { _ = writeln!( &mut output, "{} {}", colors::yellow("╭"), colors::yellow_bold("Warning") ); _ = writeln!(&mut output, "{}", colors::yellow("│")); _ = writeln!( &mut output, "{} Ignored build scripts for packages:", colors::yellow("│"), ); for (package, _) in packages { _ = writeln!( &mut output, "{} {}", colors::yellow("│"), colors::italic(format!("npm:{}", package.id.nv)) ); } _ = writeln!(&mut output, "{}", colors::yellow("│")); _ = writeln!( &mut output, "{} Run \"{}\" to run build scripts.", colors::yellow("│"), colors::bold("deno approve-scripts") ); _ = write!(&mut output, "{}", colors::yellow("╰─")); if let Some(install_reporter) = &self.install_reporter { let paths = packages .iter() .map(|(package, _)| self.warned_scripts_file(package)) .collect::>(); install_reporter.scripts_not_run_warning( crate::lifecycle_scripts::LifecycleScriptsWarning::new( output, Box::new(move |sys| { for path in paths { let _ignore_err = create_initialized_file(sys, &path); } }), ), ); } else { log::info!("{}", output); for (package, _) in packages { let _ignore_err = create_initialized_file( self.sys, &self.warned_scripts_file(package), ); } } } Ok(()) } fn has_warned(&self, package: &NpmResolutionPackage) -> bool { self.sys.fs_exists_no_err(self.warned_scripts_file(package)) } fn has_run(&self, package: &NpmResolutionPackage) -> bool { self .sys .fs_exists_no_err(ran_scripts_file(self.deno_local_registry_dir, package)) } } // Uses BTreeMap to preserve the ordering of the elements in memory, to ensure // the file generated from this datastructure is deterministic. // See: https://github.com/denoland/deno/issues/24479 /// Represents a dependency at `node_modules/.deno//` struct SetupCacheDep<'a> { previous: Option<&'a BTreeMap>, current: &'a mut BTreeMap, } impl SetupCacheDep<'_> { pub fn insert(&mut self, name: &str, target_folder_name: &str) -> bool { self .current .insert(name.to_string(), target_folder_name.to_string()); if let Some(previous_target) = self.previous.and_then(|p| p.get(name)) { previous_target != target_folder_name } else { true } } } // Uses BTreeMap to preserve the ordering of the elements in memory, to ensure // the file generated from this datastructure is deterministic. // See: https://github.com/denoland/deno/issues/24479 #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] struct SetupCacheData { root_symlinks: BTreeMap, deno_symlinks: BTreeMap, dep_symlinks: BTreeMap>, #[serde(default)] clean_packages_hash: u64, } /// It is very slow to try to re-setup the symlinks each time, so this will /// cache what we've setup on the last run and only update what is necessary. /// Obviously this could lead to issues if the cache gets out of date with the /// file system, such as if the user manually deletes a symlink. pub struct LocalSetupCache { file_path: PathBuf, previous: Option, current: SetupCacheData, sys: TSys, } impl LocalSetupCache { pub fn load(sys: TSys, file_path: PathBuf) -> Self { let previous = sys .fs_read(&file_path) .ok() .and_then(|data| bincode::deserialize(&data).ok()); Self { file_path, previous, current: Default::default(), sys, } } pub fn save(&self) -> bool { if let Some(previous) = &self.previous && previous == &self.current { return false; // nothing to save } const CACHE_PERM: u32 = 0o644; bincode::serialize(&self.current).ok().and_then(|data| { atomic_write_file_with_retries( &self.sys, &self.file_path, &data, CACHE_PERM, ) .ok() }); true } pub fn remove_root_symlink(&mut self, name: &str) { self.current.root_symlinks.remove(name); } pub fn remove_deno_symlink(&mut self, name: &str) { self.current.deno_symlinks.remove(name); } /// Inserts and checks for the existence of a root symlink /// at `node_modules/` pointing to /// `node_modules/.deno//` pub fn insert_root_symlink( &mut self, name: &str, target_folder_name: &str, ) -> bool { self .current .root_symlinks .insert(name.to_string(), target_folder_name.to_string()); if let Some(previous_target) = self .previous .as_ref() .and_then(|p| p.root_symlinks.get(name)) { previous_target != target_folder_name } else { true } } /// Inserts and checks for the existence of a symlink at /// `node_modules/.deno/node_modules/` pointing to /// `node_modules/.deno//` pub fn insert_deno_symlink( &mut self, name: &str, target_folder_name: &str, ) -> bool { self .current .deno_symlinks .insert(name.to_string(), target_folder_name.to_string()); if let Some(previous_target) = self .previous .as_ref() .and_then(|p| p.deno_symlinks.get(name)) { previous_target != target_folder_name } else { true } } pub fn remove_dep(&mut self, parent_name: &str) { if let Some(previous) = &mut self.previous { previous.dep_symlinks.remove(parent_name); } } fn with_dep(&mut self, parent_name: &str) -> SetupCacheDep<'_> { SetupCacheDep { previous: self .previous .as_ref() .and_then(|p| p.dep_symlinks.get(parent_name)), current: self .current .dep_symlinks .entry(parent_name.to_string()) .or_default(), } } /// Checks if the packages have changed since the last setup pub fn packages_changed(&self, current_hash: u64) -> bool { self .previous .as_ref() .map(|p| p.clean_packages_hash != current_hash) .unwrap_or(true) // If no previous cache, consider it changed } pub fn clear_previous(&mut self) { self.previous = None; } /// Updates the packages hash in the current cache pub fn set_clean_packages_hash(&mut self, hash: u64) { self.current.clean_packages_hash = hash; } } /// Builds a link destination from an unscoped or scoped package name. fn package_link_path( node_modules: &Path, name: &str, ) -> std::io::Result { let valid_part = |part: &str| { !part.is_empty() && part != "." && part != ".." && !part.ends_with(['.', ' ']) && !part.contains(['\\', ':', '\0']) }; let valid = if let Some(scoped) = name.strip_prefix('@') { scoped.split_once('/').is_some_and(|(scope, package)| { valid_part(scope) && valid_part(package) && !package.contains('/') }) } else { valid_part(name) && !name.contains('/') }; if !valid || [".bin", ".deno", "node_modules"] .iter() .any(|reserved| name.eq_ignore_ascii_case(reserved)) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("Invalid npm package name: {name:?}"), )); } Ok(join_package_name(Cow::Borrowed(node_modules), name)) } pub(crate) fn symlink_package_dir( sys: &( impl sys_traits::FsSymlinkDir + sys_traits::FsRemoveDir + sys_traits::FsRemoveFile + sys_traits::FsRemoveDirAll + sys_traits::FsCreateDirAll + sys_traits::FsCreateJunction ), old_path: &Path, node_modules: &Path, name: &str, ) -> Result<(), std::io::Error> { let new_path = package_link_path(node_modules, name)?; let new_path = new_path.as_path(); let sys = sys.with_paths_in_errors(); let new_parent = new_path.parent().unwrap(); if new_parent.file_name().unwrap() != "node_modules" { // create the parent folder that will contain the symlink sys.fs_create_dir_all(new_parent)? } // need to delete the previous symlink before creating a new one let _ignore = remove_existing_entry(sys.as_ref(), new_path); let old_path_relative = relative_path(new_parent, old_path) .unwrap_or_else(|| old_path.to_path_buf()); if sys_traits::impls::is_windows() { junction_or_symlink_dir( sys.as_ref(), &old_path_relative, old_path, new_path, ) } else { symlink_dir(sys.as_ref(), &old_path_relative, new_path) } } fn relative_path(from: &Path, to: &Path) -> Option { pathdiff::diff_paths(to, from) } /// Removes whatever currently exists at `path` (file, directory, symlink, or /// junction) so a fresh symlink/junction can be created in its place. A stale /// entry can be left behind when a `node_modules` directory is reused across /// runs, for example when it's restored from a CI cache. fn remove_existing_entry( sys: &( impl sys_traits::FsRemoveDir + sys_traits::FsRemoveFile + sys_traits::FsRemoveDirAll ), path: &Path, ) -> Result<(), std::io::Error> { let is_not_found = |err: &std::io::Error| err.kind() == std::io::ErrorKind::NotFound; // First try the syscall appropriate for the entry's actual type. Its error // is discarded: if it fails we fall through to the recursive removal below // and surface that error instead, since it's the last and most complete // attempt. if sys_traits::impls::is_windows() { // On Windows a directory symlink or junction must be removed with // RemoveDirectory rather than DeleteFile, and `remove_dir_all` may fail on // a dangling directory symlink, so remove the link itself first. match sys.fs_remove_dir(path) { Ok(()) => return Ok(()), Err(err) if is_not_found(&err) => return Ok(()), Err(_) => {} } // It may instead be a file symlink, which needs DeleteFile. match sys.fs_remove_file(path) { Ok(()) => return Ok(()), Err(err) if is_not_found(&err) => return Ok(()), Err(_) => {} } } else { // On Unix unlinking a symlink does not follow it. match sys.fs_remove_file(path) { Ok(()) => return Ok(()), Err(err) if is_not_found(&err) => return Ok(()), Err(_) => {} } } // Fall back to removing a real (possibly non-empty) directory, surfacing // this error if even the recursive removal fails. match sys.fs_remove_dir_all(path) { Ok(()) => Ok(()), Err(err) if is_not_found(&err) => Ok(()), Err(err) => Err(err), } } /// Removes managed dependency links from a workspace member's `node_modules` /// whose alias is no longer one of the member's current direct dependencies /// (`keep_aliases`). Without this a dependency dropped from the member's /// `package.json`, or a sibling workspace member that was removed, would leave a /// stale link behind and stay resolvable from the member, recreating exactly the /// shadow-dependency problem the per-member layout is meant to prevent. This is /// the per-member counterpart to the root `node_modules` pruning done by /// [`remove_unused_node_modules_symlinks`]. /// /// Only symlinks and junctions are touched; real files or directories a user /// placed in the member's `node_modules` are left untouched. pub(crate) fn remove_stale_member_symlinks( sys: &TSys, member_node_modules: &Path, keep_aliases: &HashSet<&str>, ) { let paths_sys = sys.with_paths_in_errors(); let entries = match paths_sys.fs_read_dir(member_node_modules) { Ok(entries) => entries, // The directory may not exist yet (first install) or be unreadable; either // way there is nothing to prune. Err(_) => return, }; for entry in entries.flatten() { let file_name = entry.file_name(); let Some(name) = file_name.to_str() else { continue; }; // Leave `.bin`, `.deno`, and any other dot-entries alone: they are not the // per-dependency alias links this loop manages. if name.starts_with('.') { continue; } let entry_path = member_node_modules.join(&file_name); if name.starts_with('@') { // Scoped packages are linked one level deeper as `@scope/`, so the // alias to match against is the full `@scope/` path. if let Ok(children) = paths_sys.fs_read_dir(&entry_path) { for child in children.flatten() { let child_file_name = child.file_name(); let Some(child_name) = child_file_name.to_str() else { continue; }; let child_path = entry_path.join(&child_file_name); let alias = format!("{name}/{child_name}"); if paths_sys.fs_read_link(&child_path).is_ok() && !keep_aliases.contains(alias.as_str()) { let _ignore = remove_existing_entry(sys, &child_path); } } } // Drop the scope directory if pruning emptied it. if paths_sys .fs_read_dir(&entry_path) .map(|mut entries| entries.next().is_none()) .unwrap_or(false) { let _ignore = paths_sys.fs_remove_dir(&entry_path); } } else if paths_sys.fs_read_link(&entry_path).is_ok() && !keep_aliases.contains(name) { let _ignore = remove_existing_entry(sys, &entry_path); } } } /// A workspace member's direct dependency that may contribute executables to /// the member's `node_modules/.bin`. pub(crate) struct MemberBinDep<'a> { /// The resolved npm package, used for its `bin` metadata. pub package: &'a NpmResolutionPackage, /// Where the package's `package.json` is read from: its real location in the /// layout (the `.deno` store path for the isolated linker, or the hoisted /// package directory for the hoisted linker). pub read_path: PathBuf, /// The member's own link to the package /// (`/node_modules/`). The generated shim points here so a /// tool invoked as `node_modules/.bin/` from within the member resolves /// the member's copy. pub link_path: PathBuf, } /// Sets up (and prunes) a workspace member's `node_modules/.bin`, mirroring how /// npm and pnpm populate each member's local `.bin` so tools invoked as /// `node_modules/.bin/` from within the member (for example `eslint`, /// `svelte-check`, or `astro`) resolve the member's own dependencies. /// /// Any pre-existing entries are cleared first so an executable from a /// dependency the member dropped stops resolving. This mirrors how the root /// `.bin` is rebuilt, and it is the per-member counterpart that the /// symlink-only [`remove_stale_member_symlinks`] cannot provide: on Windows the /// shims are plain files (``, `.cmd`, `.ps1`) rather than /// symlinks. /// /// Sibling workspace members are not npm packages and so are not included in /// `bin_deps`; their executables are not linked into a member's `.bin` yet. pub(crate) async fn setup_member_bin_entries<'a, TSys: LocalNpmInstallSys>( sys: SysWithPathsInErrors<'a, TSys>, snapshot: &'a NpmResolutionSnapshot, extra_info_provider: &CachedNpmPackageExtraInfoProvider, member_node_modules: &Path, bin_deps: &[MemberBinDep<'a>], ) -> Result<(), SyncResolutionWithFsError> { let member_bin_dir = member_node_modules.join(".bin"); // Clear any existing `.bin` entries before re-creating them so executables // from dropped dependencies don't linger. Runs unconditionally (even with no // bins to add) so a member that dropped its last bin dependency is cleaned up // too. A member's `.bin` is small, so rebuilding it each install is cheap. if let Ok(entries) = sys.fs_read_dir(&member_bin_dir) { for entry in entries.flatten() { let Ok(file_type) = entry.file_type() else { continue; }; if file_type.is_file() { let _ignore = sys.fs_remove_file(entry.path()); } else { let _ignore = sys.fs_remove_dir_all(entry.path()); } } } let mut bin_entries = BinEntries::new(sys); for dep in bin_deps { if !dep.package.has_bin { continue; } // Cached from the root setup that ran earlier, so this is a map lookup // rather than a disk read in the common case. let extra = extra_info_provider .get_package_extra_info( &dep.package.id.nv, &dep.read_path, ExpectedExtraInfo::from_package(dep.package), ) .await .map_err(SyncResolutionWithFsError::Other)?; bin_entries.add(dep.package, &extra, dep.link_path.clone()); } // Ignore setup failures here: every package linked into a member is also // linked at the root, whose `.bin` setup already reports a missing entrypoint // or a not-yet-run lifecycle script. Warning again per member would be noise. bin_entries.finish(snapshot, &member_bin_dir, |_outcome| {})?; Ok(()) } /// Runs `create`, and if it fails because something already exists at `path`, /// removes that entry and tries once more. This makes creating a symlink or /// junction resilient to a stale entry left over from a previous run. fn create_retry_if_exists( sys: &( impl sys_traits::FsRemoveDir + sys_traits::FsRemoveFile + sys_traits::FsRemoveDirAll ), path: &Path, mut create: impl FnMut() -> Result<(), std::io::Error>, ) -> Result<(), std::io::Error> { match create() { Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { remove_existing_entry(sys, path)?; create() } result => result, } } fn junction_or_symlink_dir( sys: &( impl sys_traits::FsSymlinkDir + sys_traits::FsCreateJunction + sys_traits::FsRemoveDir + sys_traits::FsRemoveFile + sys_traits::FsRemoveDirAll ), old_path_relative: &Path, old_path: &Path, new_path: &Path, ) -> Result<(), std::io::Error> { static USE_JUNCTIONS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); let sys = sys.with_paths_in_errors(); // Use junctions because they're supported on ntfs file systems without // needing to elevate privileges on Windows. // Note: junctions don't support relative paths, so we need to use the // absolute path here. let create_junction = || { create_retry_if_exists(sys.as_ref(), new_path, || { sys.fs_create_junction(old_path, new_path) }) }; if USE_JUNCTIONS.load(std::sync::atomic::Ordering::Relaxed) { return create_junction(); } match create_retry_if_exists(sys.as_ref(), new_path, || { symlink_dir(sys.as_ref(), old_path_relative, new_path) }) { Ok(()) => Ok(()), Err(symlink_err) if symlink_err.kind() == std::io::ErrorKind::PermissionDenied => { USE_JUNCTIONS.store(true, std::sync::atomic::Ordering::Relaxed); create_junction() } Err(symlink_err) => { log::warn!( "{} Unexpected error symlinking node_modules: {symlink_err}", colors::yellow("Warning") ); USE_JUNCTIONS.store(true, std::sync::atomic::Ordering::Relaxed); create_junction() } } } fn write_initialized_file( sys: &(impl FsWrite + FsOpen), path: &Path, text: &str, ) -> Result<(), std::io::Error> { let sys = sys.with_paths_in_errors(); if text.is_empty() { // one less syscall create_initialized_file(sys.as_ref(), path) } else { sys.fs_write(path, text) } } fn create_initialized_file( sys: &F, path: &Path, ) -> Result<(), std::io::Error> { let sys = sys.with_paths_in_errors(); sys .fs_open_boxed(path, &sys_traits::OpenOptions::new_write()) .map(|_| ()) } pub(crate) fn join_package_name( mut path: Cow<'_, Path>, package_name: &str, ) -> PathBuf { // ensure backslashes are used on windows for part in package_name.split('/') { match path { Cow::Borrowed(inner) => path = Cow::Owned(inner.join(part)), Cow::Owned(ref mut path) => { path.push(part); } } } path.into_owned() } /// Calculates a hash of the current package set for change detection. /// This allows us to detect when npm packages have been added, removed, or changed. fn calculate_packages_hash( package_partitions: &deno_npm::resolution::NpmPackagesPartitioned, root_folder_names: &BTreeSet, ) -> u64 { use std::hash::Hash; use std::hash::Hasher; let mut hasher = twox_hash::XxHash64::default(); // the order of the packages is non-deterministic (hash map iteration // during resolution traversal), so sort the ids before hashing let mut package_ids = package_partitions .iter_all() .map(|package| &package.id) .collect::>(); package_ids.sort_unstable(); for package_id in package_ids { package_id.hash(&mut hasher); } // also hash the packages expected at the root of node_modules so that // cleanup runs when a direct dependency is removed but stays in the // resolution as a transitive dependency for folder_name in root_folder_names { folder_name.hash(&mut hasher); } hasher.finish() } /// Calculates the set of package folder names that are expected to have a /// symlink at the root of the node_modules directory (resolved package.json /// and import map dependencies plus the snapshot's top level packages). fn root_package_folder_names( snapshot: &NpmResolutionSnapshot, npm_install_deps_provider: &NpmInstallDepsProvider, ) -> BTreeSet { let mut folder_names = BTreeSet::new(); for id in snapshot.top_level_packages() { if let Some(package) = snapshot.package_from_id(id) { folder_names.insert(get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), )); } } // resolve the same way as when creating the root symlinks for // package.json dependencies for remote in npm_install_deps_provider.remote_pkgs() { let package = match snapshot.resolve_pkg_from_pkg_req(&remote.req) { Ok(package) => package, _ => { if remote.req.version_req.tag().is_some() { continue; } match snapshot .resolve_best_package_id(&remote.req.name, &remote.req.version_req) .and_then(|id| snapshot.package_from_id(&id)) { Some(package) => package, None => continue, } } }; folder_names.insert(get_package_folder_id_folder_name( &package.get_package_cache_folder_id(), )); } folder_names } /// Cleans up unused packages from the node_modules/.deno directory. /// This removes any package folders that are not part of the current resolution. fn cleanup_unused_packages( sys: &TSys, root_node_modules_dir: &Path, deno_local_registry_dir: &Path, package_partitions: &deno_npm::resolution::NpmPackagesPartitioned, root_folder_names: &BTreeSet, setup_cache: &mut LocalSetupCache, ) { // Collect all package folder names that should exist in .deno/ let expected_folders: HashSet<_> = package_partitions .iter_all() .map(|package| { get_package_folder_id_folder_name(&package.get_package_cache_folder_id()) }) .collect(); // Clean up package folders in .deno/ that are no longer needed if let Ok(entries) = sys.fs_read_dir(deno_local_registry_dir) { for entry in entries.flatten() { let file_name = entry.file_name(); if let Some(name_str) = file_name.to_str() { if name_str == "node_modules" || name_str.starts_with(".deno.lock") { continue; } // If this folder is not expected, remove it if !expected_folders.contains(name_str) { let path = deno_local_registry_dir.join(file_name); let _ignore = sys.fs_remove_dir_all(&path); } } } } // Build set of package folder names that should exist let keep_names = package_partitions .iter_all() .map(|package| { get_package_folder_id_folder_name(&package.get_package_cache_folder_id()) }) .collect::>(); // Clean up .deno/node_modules/* symlinks for packages no longer needed let deno_node_modules_dir = deno_local_registry_dir.join("node_modules"); let _ignore = remove_unused_node_modules_symlinks( sys, &deno_node_modules_dir, &keep_names, &mut |name, path| { setup_cache.remove_deno_symlink(name); remove_existing_entry(sys, path) }, ); // Clean up root node_modules/* symlinks for packages that should no longer // be linked at the root. Only direct dependencies and top level packages // get a root symlink, so a package that remains in the resolution solely as // a transitive dependency must not stay linked at the root (#35083). let root_keep_names = root_folder_names.iter().cloned().collect::>(); let _ignore = remove_unused_node_modules_symlinks( sys, root_node_modules_dir, &root_keep_names, &mut |name, path| { setup_cache.remove_root_symlink(name); remove_existing_entry(sys, path) }, ); // remove the .bin directory entries let bin_dir = root_node_modules_dir.join(".bin"); if let Ok(entries) = sys.fs_read_dir(&bin_dir) { for entry in entries.flatten() { let Ok(file_type) = entry.file_type() else { continue; }; if file_type.is_file() { let _ignore = sys.fs_remove_file(entry.path()); } else { let _ignore = sys.fs_remove_dir_all(entry.path()); } } } } /// Extracts the package folder name from a node_modules symlink target path. /// e.g. node_modules/.deno/chalk@5.0.1/node_modules/chalk -> chalk@5.0.1 pub fn node_modules_package_actual_dir_to_name( path: &Path, ) -> Option> { let package_folder = path.parent()?.parent()?; if package_folder.file_name()? == std::ffi::OsStr::new("node_modules") { package_folder .parent()? .file_name() .map(|name| name.to_string_lossy()) } else { package_folder .file_name() .map(|name| name.to_string_lossy()) } } /// Remove symlinks from a node_modules directory where the target package /// is not in the keep_names set. The on_remove callback is responsible for /// the actual removal and receives the package name and path. pub fn remove_unused_node_modules_symlinks( sys: &TSys, dir: &Path, keep_names: &HashSet, on_remove: &mut dyn FnMut(&str, &Path) -> std::io::Result<()>, ) -> Result<(), std::io::Error> { let sys = sys.with_paths_in_errors(); let entries = match sys.fs_read_dir(dir) { Ok(entries) => entries, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), // Directory doesn't exist, nothing to clean Err(e) => return Err(e), }; for entry in entries.flatten() { let entry_path = dir.join(entry.file_name()); // use fs_read_link to detect both symlinks and junctions on Windows // (is_symlink() returns false for junctions) if let Ok(target) = sys.fs_read_link(&entry_path) { let name = node_modules_package_actual_dir_to_name(&target); if let Some(name) = name && !keep_names.contains(&*name) { on_remove(&name, &entry_path)?; } } else if entry.file_name().to_string_lossy().starts_with('@') && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { remove_unused_node_modules_symlinks( sys.as_ref(), &entry_path, keep_names, on_remove, )?; if sys .fs_read_dir(&entry_path) .map(|mut entries| entries.next().is_none()) .unwrap_or(false) { let _ignore = sys.fs_remove_dir(&entry_path); } } } Ok(()) } #[cfg(test)] mod test { use sys_traits::FsCreateDirAll; use sys_traits::FsMetadata; use sys_traits::FsRead; use sys_traits::FsSymlinkDir; use sys_traits::FsWrite; use test_util::TempDir; use super::*; #[test] fn clone_dir_recursive_except_node_modules_child_rejects_symlink_destination() { let sys = sys_traits::impls::InMemorySys::default(); let from = Path::new("/from"); let to = Path::new("/to"); let target = Path::new("/target"); sys.fs_create_dir_all(from).unwrap(); sys.fs_create_dir_all(target).unwrap(); sys.fs_write(from.join("file"), "package contents").unwrap(); sys.fs_symlink_dir(target, to).unwrap(); let err = clone_dir_recursive_except_node_modules_child(&sys, from, to) .unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); assert!(sys.fs_read_to_string(target.join("file")).is_err()); } #[test] fn test_setup_cache() { let temp_dir = TempDir::new(); let cache_bin_path = temp_dir.path().join("cache.bin").to_path_buf(); let sys = sys_traits::impls::RealSys; let mut cache = LocalSetupCache::load(sys.clone(), cache_bin_path.clone()); assert!(cache.insert_deno_symlink("package-a", "package-a@1.0.0")); assert!(cache.insert_root_symlink("package-a", "package-a@1.0.0")); assert!( cache .with_dep("package-a") .insert("package-b", "package-b@1.0.0") ); assert!(cache.save()); let mut cache = LocalSetupCache::load(sys.clone(), cache_bin_path.clone()); assert!(!cache.insert_deno_symlink("package-a", "package-a@1.0.0")); assert!(!cache.insert_root_symlink("package-a", "package-a@1.0.0")); assert!( !cache .with_dep("package-a") .insert("package-b", "package-b@1.0.0") ); assert!(!cache.save()); assert!(cache.insert_root_symlink("package-b", "package-b@0.2.0")); assert!(cache.save()); let mut cache = LocalSetupCache::load(sys, cache_bin_path); cache.remove_dep("package-a"); assert!( cache .with_dep("package-a") .insert("package-b", "package-b@1.0.0") ); } #[test] fn test_calculate_packages_hash_order_independent() { fn pkg(id: &str) -> NpmResolutionPackage { NpmResolutionPackage { id: NpmPackageId::from_serialized(id).unwrap(), copy_index: 0, system: Default::default(), dist: None, dependencies: Default::default(), optional_dependencies: Default::default(), optional_peer_dependencies: Default::default(), extra: None, is_deprecated: false, has_bin: false, has_scripts: false, } } let packages = vec![ pkg("package-a@1.0.0"), pkg("package-b@2.0.0"), pkg("package-c@3.0.0"), ]; let mut reversed_packages = packages.clone(); reversed_packages.reverse(); let root_folder_names = BTreeSet::from(["package-a".to_string(), "package-b".to_string()]); let hash = calculate_packages_hash( &deno_npm::resolution::NpmPackagesPartitioned { packages, copy_packages: Vec::new(), }, &root_folder_names, ); let reversed_hash = calculate_packages_hash( &deno_npm::resolution::NpmPackagesPartitioned { packages: reversed_packages, copy_packages: Vec::new(), }, &root_folder_names, ); assert_eq!(hash, reversed_hash); } #[test] fn test_symlink_package_dir_rejects_invalid_names() { let temp_dir = TempDir::new(); let sys = sys_traits::impls::RealSys; let root = temp_dir.path().to_path_buf(); let target = root.join("target"); let outside = root.join("outside"); let node_modules = root.join("node_modules"); for path in [&target, &outside, &node_modules] { sys.fs_create_dir_all(path).unwrap(); } sys.fs_write(outside.join("marker.txt"), "keep").unwrap(); let absolute = outside.to_str().unwrap(); for name in [ "", ".", "..", "../outside", "../../outside", absolute, "@scope/../outside", "@../outside", "@scope/..", "@/pkg", "@scope/", "pkg/child", "pkg/", "pkg\\child", "C:outside", ".. ", "@scope/.. ", "pkg.", ".bin", ".deno", ".DENO", "node_modules", ] { let err = symlink_package_dir(&sys, &target, &node_modules, name).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "{name:?}"); assert_eq!( sys.fs_read_to_string(outside.join("marker.txt")).unwrap(), "keep" ); } for name in ["pkg", "@scope/pkg"] { symlink_package_dir(&sys, &outside, &node_modules, name).unwrap(); assert_eq!( sys .fs_read_to_string(node_modules.join(name).join("marker.txt")) .unwrap(), "keep" ); } } #[test] fn test_symlink_package_dir_replaces_existing_link() { let temp_dir = TempDir::new(); let sys = sys_traits::impls::RealSys; let root = temp_dir.path().to_path_buf(); let target_a = root.join("target_a"); let target_b = root.join("target_b"); sys.fs_create_dir_all(&target_a).unwrap(); sys.fs_create_dir_all(&target_b).unwrap(); sys.fs_write(target_a.join("marker.txt"), "a").unwrap(); sys.fs_write(target_b.join("marker.txt"), "b").unwrap(); let node_modules = root.join("node_modules"); sys.fs_create_dir_all(&node_modules).unwrap(); let link = node_modules.join("pkg"); // First the link points at target_a. symlink_package_dir(&sys, &target_a, &node_modules, "pkg").unwrap(); assert_eq!(sys.fs_read_to_string(link.join("marker.txt")).unwrap(), "a"); // Re-creating over the pre-existing link must succeed (this is what // regressed on Windows: a stale directory symlink/junction has to be // removed before the new one can be created) and now resolve to target_b. symlink_package_dir(&sys, &target_b, &node_modules, "pkg").unwrap(); assert_eq!(sys.fs_read_to_string(link.join("marker.txt")).unwrap(), "b"); } #[test] fn test_create_retry_if_exists_clears_stale_entry() { let temp_dir = TempDir::new(); let sys = sys_traits::impls::RealSys; let path = temp_dir.path().join("entry").to_path_buf(); // A stale entry is sitting where we want to create something new. sys.fs_create_dir_all(&path).unwrap(); let mut attempts = 0; let result = create_retry_if_exists(&sys, &path, || { attempts += 1; if attempts == 1 { // Simulate creation failing because the stale entry is in the way. Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "already exists", )) } else { Ok(()) } }); assert!(result.is_ok()); assert_eq!(attempts, 2); // The stale entry must have been removed before the retry. assert!(!sys.fs_exists(&path).unwrap()); } #[test] fn test_create_retry_if_exists_passes_through_success() { let temp_dir = TempDir::new(); let sys = sys_traits::impls::RealSys; let path = temp_dir.path().join("entry").to_path_buf(); let mut attempts = 0; let result = create_retry_if_exists(&sys, &path, || { attempts += 1; Ok(()) }); assert!(result.is_ok()); assert_eq!(attempts, 1); } #[test] fn test_node_modules_package_actual_dir_to_name() { assert_eq!( node_modules_package_actual_dir_to_name(Path::new( ".deno/chalk@5.0.1/node_modules/chalk" )) .as_deref(), Some("chalk@5.0.1") ); assert_eq!( node_modules_package_actual_dir_to_name(Path::new( ".deno/@denotest+add@1.0.0/node_modules/@denotest/add" )) .as_deref(), Some("@denotest+add@1.0.0") ); } #[test] fn test_jsr_npm_name_to_original() { assert_eq!( jsr_npm_name_to_original("@jsr/std__bytes").as_deref(), Some("@std/bytes") ); assert_eq!( jsr_npm_name_to_original("@jsr/david__dax").as_deref(), Some("@david/dax") ); // not a `@jsr/` package assert_eq!(jsr_npm_name_to_original("chalk"), None); assert_eq!(jsr_npm_name_to_original("@std/bytes"), None); // missing the `__` separator assert_eq!(jsr_npm_name_to_original("@jsr/std"), None); } #[test] fn test_ensure_jsr_npmrc() { let temp_dir = TempDir::new(); let sys = sys_traits::impls::RealSys; let dir = temp_dir.path().to_path_buf(); let npmrc_path = dir.join(".npmrc"); // 1. No existing `.npmrc`: it gets created with the JSR registry line. ensure_jsr_npmrc(&sys, &dir).unwrap(); assert_eq!( sys.fs_read_to_string(&npmrc_path).unwrap(), "@jsr:registry=https://npm.jsr.io\n" ); // 2. Running again is idempotent (the entry already exists). ensure_jsr_npmrc(&sys, &dir).unwrap(); assert_eq!( sys.fs_read_to_string(&npmrc_path).unwrap(), "@jsr:registry=https://npm.jsr.io\n" ); // 3. An existing `.npmrc` without the entry gets it appended (preserving the // existing contents and adding a separating newline). sys .fs_write(&npmrc_path, "registry=https://example.com") .unwrap(); ensure_jsr_npmrc(&sys, &dir).unwrap(); assert_eq!( sys.fs_read_to_string(&npmrc_path).unwrap(), "registry=https://example.com\n@jsr:registry=https://npm.jsr.io\n" ); // 4. A pre-existing `@jsr` registry configuration is left untouched. sys .fs_write(&npmrc_path, "@jsr:registry=https://example.com/jsr\n") .unwrap(); ensure_jsr_npmrc(&sys, &dir).unwrap(); assert_eq!( sys.fs_read_to_string(&npmrc_path).unwrap(), "@jsr:registry=https://example.com/jsr\n" ); } }