diff options
author | Nathan Whitaker <17734409+nathanwhit@users.noreply.github.com> | 2024-09-24 12:23:57 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-09-24 19:23:57 +0000 |
commit | 36ebc03f177cc7db5deb93f4d403cafbed756eb5 (patch) | |
tree | c36af6c9b7093d3191de3cd6e60c4ce9dca4151a /cli/npm/managed/resolvers/common | |
parent | ba5b8d0213cde2585236098b00beb8a512889626 (diff) |
fix(cli): Warn on not-run lifecycle scripts with global cache (#25786)
Refactors the lifecycle scripts code to extract out the common
functionality and then uses that to provide a warning in the global
resolver.
While ideally we would still support them with the global cache, for now
a warning is at least better than the status quo (where people are
unaware why their packages aren't working).
Diffstat (limited to 'cli/npm/managed/resolvers/common')
-rw-r--r-- | cli/npm/managed/resolvers/common/bin_entries.rs | 328 | ||||
-rw-r--r-- | cli/npm/managed/resolvers/common/lifecycle_scripts.rs | 306 |
2 files changed, 634 insertions, 0 deletions
diff --git a/cli/npm/managed/resolvers/common/bin_entries.rs b/cli/npm/managed/resolvers/common/bin_entries.rs new file mode 100644 index 000000000..25a020c2b --- /dev/null +++ b/cli/npm/managed/resolvers/common/bin_entries.rs @@ -0,0 +1,328 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use crate::npm::managed::NpmResolutionPackage; +use deno_core::anyhow::Context; +use deno_core::error::AnyError; +use deno_npm::resolution::NpmResolutionSnapshot; +use deno_npm::NpmPackageId; +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Default)] +pub struct BinEntries<'a> { + /// Packages that have colliding bin names + collisions: HashSet<&'a NpmPackageId>, + seen_names: HashMap<&'a str, &'a NpmPackageId>, + /// The bin entries + entries: Vec<(&'a NpmResolutionPackage, PathBuf)>, +} + +/// Returns the name of the default binary for the given package. +/// This is the package name without the organization (`@org/`), if any. +fn default_bin_name(package: &NpmResolutionPackage) -> &str { + package + .id + .nv + .name + .rsplit_once('/') + .map_or(package.id.nv.name.as_str(), |(_, name)| name) +} + +impl<'a> BinEntries<'a> { + pub fn new() -> Self { + Self::default() + } + + /// Add a new bin entry (package with a bin field) + pub fn add( + &mut self, + package: &'a NpmResolutionPackage, + package_path: PathBuf, + ) { + // check for a new collision, if we haven't already + // found one + match package.bin.as_ref().unwrap() { + deno_npm::registry::NpmPackageVersionBinEntry::String(_) => { + let bin_name = default_bin_name(package); + + if let Some(other) = self.seen_names.insert(bin_name, &package.id) { + self.collisions.insert(&package.id); + self.collisions.insert(other); + } + } + deno_npm::registry::NpmPackageVersionBinEntry::Map(entries) => { + for name in entries.keys() { + if let Some(other) = self.seen_names.insert(name, &package.id) { + self.collisions.insert(&package.id); + self.collisions.insert(other); + } + } + } + } + + self.entries.push((package, package_path)); + } + + fn for_each_entry( + &mut self, + snapshot: &NpmResolutionSnapshot, + mut f: impl FnMut( + &NpmResolutionPackage, + &Path, + &str, // bin name + &str, // bin script + ) -> Result<(), AnyError>, + ) -> Result<(), AnyError> { + if !self.collisions.is_empty() { + // walking the dependency tree to find out the depth of each package + // is sort of expensive, so we only do it if there's a collision + sort_by_depth(snapshot, &mut self.entries, &mut self.collisions); + } + + let mut seen = HashSet::new(); + + for (package, package_path) in &self.entries { + if let Some(bin_entries) = &package.bin { + match bin_entries { + deno_npm::registry::NpmPackageVersionBinEntry::String(script) => { + let name = default_bin_name(package); + if !seen.insert(name) { + // we already set up a bin entry with this name + continue; + } + f(package, package_path, name, script)?; + } + deno_npm::registry::NpmPackageVersionBinEntry::Map(entries) => { + for (name, script) in entries { + if !seen.insert(name) { + // we already set up a bin entry with this name + continue; + } + f(package, package_path, name, script)?; + } + } + } + } + } + + Ok(()) + } + + /// Collect the bin entries into a vec of (name, script path) + pub fn into_bin_files( + mut self, + snapshot: &NpmResolutionSnapshot, + ) -> Vec<(String, PathBuf)> { + let mut bins = Vec::new(); + self + .for_each_entry(snapshot, |_, package_path, name, script| { + bins.push((name.to_string(), package_path.join(script))); + Ok(()) + }) + .unwrap(); + bins + } + + /// Finish setting up the bin entries, writing the necessary files + /// to disk. + pub fn finish( + mut self, + snapshot: &NpmResolutionSnapshot, + bin_node_modules_dir_path: &Path, + ) -> Result<(), AnyError> { + if !self.entries.is_empty() && !bin_node_modules_dir_path.exists() { + std::fs::create_dir_all(bin_node_modules_dir_path).with_context( + || format!("Creating '{}'", bin_node_modules_dir_path.display()), + )?; + } + + self.for_each_entry(snapshot, |package, package_path, name, script| { + set_up_bin_entry( + package, + name, + script, + package_path, + bin_node_modules_dir_path, + ) + })?; + + Ok(()) + } +} + +// walk the dependency tree to find out the depth of each package +// that has a bin entry, then sort them by depth +fn sort_by_depth( + snapshot: &NpmResolutionSnapshot, + bin_entries: &mut [(&NpmResolutionPackage, PathBuf)], + collisions: &mut HashSet<&NpmPackageId>, +) { + enum Entry<'a> { + Pkg(&'a NpmPackageId), + IncreaseDepth, + } + + let mut seen = HashSet::new(); + let mut depths: HashMap<&NpmPackageId, u64> = + HashMap::with_capacity(collisions.len()); + + let mut queue = VecDeque::new(); + queue.extend(snapshot.top_level_packages().map(Entry::Pkg)); + seen.extend(snapshot.top_level_packages()); + queue.push_back(Entry::IncreaseDepth); + + let mut current_depth = 0u64; + + while let Some(entry) = queue.pop_front() { + if collisions.is_empty() { + break; + } + let id = match entry { + Entry::Pkg(id) => id, + Entry::IncreaseDepth => { + current_depth += 1; + if queue.is_empty() { + break; + } + queue.push_back(Entry::IncreaseDepth); + continue; + } + }; + if let Some(package) = snapshot.package_from_id(id) { + if collisions.remove(&package.id) { + depths.insert(&package.id, current_depth); + } + for dep in package.dependencies.values() { + if seen.insert(dep) { + queue.push_back(Entry::Pkg(dep)); + } + } + } + } + + bin_entries.sort_by(|(a, _), (b, _)| { + depths + .get(&a.id) + .unwrap_or(&u64::MAX) + .cmp(depths.get(&b.id).unwrap_or(&u64::MAX)) + .then_with(|| a.id.nv.cmp(&b.id.nv).reverse()) + }); +} + +pub fn set_up_bin_entry( + package: &NpmResolutionPackage, + bin_name: &str, + #[allow(unused_variables)] bin_script: &str, + #[allow(unused_variables)] package_path: &Path, + bin_node_modules_dir_path: &Path, +) -> Result<(), AnyError> { + #[cfg(windows)] + { + set_up_bin_shim(package, bin_name, bin_node_modules_dir_path)?; + } + #[cfg(unix)] + { + symlink_bin_entry( + package, + bin_name, + bin_script, + package_path, + bin_node_modules_dir_path, + )?; + } + Ok(()) +} + +#[cfg(windows)] +fn set_up_bin_shim( + package: &NpmResolutionPackage, + bin_name: &str, + bin_node_modules_dir_path: &Path, +) -> Result<(), AnyError> { + use std::fs; + let mut cmd_shim = bin_node_modules_dir_path.join(bin_name); + + cmd_shim.set_extension("cmd"); + let shim = format!("@deno run -A npm:{}/{bin_name} %*", package.id.nv); + fs::write(&cmd_shim, shim).with_context(|| { + format!("Can't set up '{}' bin at {}", bin_name, cmd_shim.display()) + })?; + + Ok(()) +} + +#[cfg(unix)] +fn symlink_bin_entry( + _package: &NpmResolutionPackage, + bin_name: &str, + bin_script: &str, + package_path: &Path, + bin_node_modules_dir_path: &Path, +) -> Result<(), AnyError> { + use std::io; + use std::os::unix::fs::symlink; + let link = bin_node_modules_dir_path.join(bin_name); + let original = package_path.join(bin_script); + + use std::os::unix::fs::PermissionsExt; + let mut perms = match std::fs::metadata(&original) { + Ok(metadata) => metadata.permissions(), + Err(err) => { + if err.kind() == io::ErrorKind::NotFound { + log::warn!( + "{} Trying to set up '{}' bin for \"{}\", but the entry point \"{}\" doesn't exist.", + deno_terminal::colors::yellow("Warning"), + bin_name, + package_path.display(), + original.display() + ); + return Ok(()); + } + return Err(err).with_context(|| { + format!("Can't set up '{}' bin at {}", bin_name, original.display()) + }); + } + }; + if perms.mode() & 0o111 == 0 { + // if the original file is not executable, make it executable + perms.set_mode(perms.mode() | 0o111); + std::fs::set_permissions(&original, perms).with_context(|| { + format!("Setting permissions on '{}'", original.display()) + })?; + } + let original_relative = + crate::util::path::relative_path(bin_node_modules_dir_path, &original) + .unwrap_or(original); + + if let Err(err) = symlink(&original_relative, &link) { + if err.kind() == io::ErrorKind::AlreadyExists { + // remove and retry + std::fs::remove_file(&link).with_context(|| { + format!( + "Failed to remove existing bin symlink at {}", + link.display() + ) + })?; + symlink(&original_relative, &link).with_context(|| { + format!( + "Can't set up '{}' bin at {}", + bin_name, + original_relative.display() + ) + })?; + return Ok(()); + } + return Err(err).with_context(|| { + format!( + "Can't set up '{}' bin at {}", + bin_name, + original_relative.display() + ) + }); + } + + Ok(()) +} diff --git a/cli/npm/managed/resolvers/common/lifecycle_scripts.rs b/cli/npm/managed/resolvers/common/lifecycle_scripts.rs new file mode 100644 index 000000000..a3c72634b --- /dev/null +++ b/cli/npm/managed/resolvers/common/lifecycle_scripts.rs @@ -0,0 +1,306 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use super::bin_entries::BinEntries; +use crate::args::LifecycleScriptsConfig; +use deno_npm::resolution::NpmResolutionSnapshot; +use deno_semver::package::PackageNv; +use std::borrow::Cow; +use std::rc::Rc; + +use std::path::Path; +use std::path::PathBuf; + +use deno_core::error::AnyError; +use deno_npm::NpmResolutionPackage; + +pub trait LifecycleScriptsStrategy { + fn can_run_scripts(&self) -> bool { + true + } + fn package_path(&self, package: &NpmResolutionPackage) -> PathBuf; + + fn warn_on_scripts_not_run( + &self, + packages: &[(&NpmResolutionPackage, PathBuf)], + ) -> Result<(), AnyError>; + + fn has_warned(&self, package: &NpmResolutionPackage) -> bool; + + fn has_run(&self, package: &NpmResolutionPackage) -> bool; + + fn did_run_scripts( + &self, + package: &NpmResolutionPackage, + ) -> Result<(), AnyError>; +} + +pub struct LifecycleScripts<'a> { + packages_with_scripts: Vec<(&'a NpmResolutionPackage, PathBuf)>, + packages_with_scripts_not_run: Vec<(&'a NpmResolutionPackage, PathBuf)>, + + config: &'a LifecycleScriptsConfig, + strategy: Box<dyn LifecycleScriptsStrategy + 'a>, +} + +impl<'a> LifecycleScripts<'a> { + pub fn new<T: LifecycleScriptsStrategy + 'a>( + config: &'a LifecycleScriptsConfig, + strategy: T, + ) -> Self { + Self { + config, + packages_with_scripts: Vec::new(), + packages_with_scripts_not_run: Vec::new(), + strategy: Box::new(strategy), + } + } +} + +fn has_lifecycle_scripts( + package: &NpmResolutionPackage, + package_path: &Path, +) -> bool { + if let Some(install) = package.scripts.get("install") { + // default script + if !is_broken_default_install_script(install, package_path) { + return true; + } + } + package.scripts.contains_key("preinstall") + || package.scripts.contains_key("postinstall") +} + +// npm defaults to running `node-gyp rebuild` if there is a `binding.gyp` file +// but it always fails if the package excludes the `binding.gyp` file when they publish. +// (for example, `fsevents` hits this) +fn is_broken_default_install_script(script: &str, package_path: &Path) -> bool { + script == "node-gyp rebuild" && !package_path.join("binding.gyp").exists() +} + +impl<'a> LifecycleScripts<'a> { + fn can_run_scripts(&self, package_nv: &PackageNv) -> bool { + if !self.strategy.can_run_scripts() { + return false; + } + use crate::args::PackagesAllowedScripts; + match &self.config.allowed { + PackagesAllowedScripts::All => true, + // TODO: make this more correct + PackagesAllowedScripts::Some(allow_list) => allow_list.iter().any(|s| { + let s = s.strip_prefix("npm:").unwrap_or(s); + s == package_nv.name || s == package_nv.to_string() + }), + PackagesAllowedScripts::None => false, + } + } + /// Register a package for running lifecycle scripts, if applicable. + /// + /// `package_path` is the path containing the package's code (its root dir). + /// `package_meta_path` is the path to serve as the base directory for lifecycle + /// script-related metadata (e.g. to store whether the scripts have been run already) + pub fn add( + &mut self, + package: &'a NpmResolutionPackage, + package_path: Cow<Path>, + ) { + if has_lifecycle_scripts(package, &package_path) { + if self.can_run_scripts(&package.id.nv) { + if !self.strategy.has_run(package) { + self + .packages_with_scripts + .push((package, package_path.into_owned())); + } + } else if !self.strategy.has_run(package) + && !self.strategy.has_warned(package) + { + self + .packages_with_scripts_not_run + .push((package, package_path.into_owned())); + } + } + } + + pub fn warn_not_run_scripts(&self) -> Result<(), AnyError> { + if !self.packages_with_scripts_not_run.is_empty() { + self + .strategy + .warn_on_scripts_not_run(&self.packages_with_scripts_not_run)?; + } + Ok(()) + } + + pub async fn finish( + self, + snapshot: &NpmResolutionSnapshot, + packages: &[NpmResolutionPackage], + root_node_modules_dir_path: Option<&Path>, + ) -> Result<(), AnyError> { + self.warn_not_run_scripts()?; + let get_package_path = + |p: &NpmResolutionPackage| self.strategy.package_path(p); + let mut failed_packages = Vec::new(); + if !self.packages_with_scripts.is_empty() { + // get custom commands for each bin available in the node_modules dir (essentially + // the scripts that are in `node_modules/.bin`) + let base = + resolve_baseline_custom_commands(snapshot, packages, get_package_path)?; + let init_cwd = &self.config.initial_cwd; + let process_state = crate::npm::managed::npm_process_state( + snapshot.as_valid_serialized(), + root_node_modules_dir_path, + ); + + let mut env_vars = crate::task_runner::real_env_vars(); + env_vars.insert( + crate::args::NPM_RESOLUTION_STATE_ENV_VAR_NAME.to_string(), + process_state, + ); + for (package, package_path) in self.packages_with_scripts { + // add custom commands for binaries from the package's dependencies. this will take precedence over the + // baseline commands, so if the package relies on a bin that conflicts with one higher in the dependency tree, the + // correct bin will be used. + let custom_commands = resolve_custom_commands_from_deps( + base.clone(), + package, + snapshot, + get_package_path, + )?; + for script_name in ["preinstall", "install", "postinstall"] { + if let Some(script) = package.scripts.get(script_name) { + if script_name == "install" + && is_broken_default_install_script(script, &package_path) + { + continue; + } + let exit_code = crate::task_runner::run_task( + crate::task_runner::RunTaskOptions { + task_name: script_name, + script, + cwd: &package_path, + env_vars: env_vars.clone(), + custom_commands: custom_commands.clone(), + init_cwd, + argv: &[], + root_node_modules_dir: root_node_modules_dir_path, + }, + ) + .await?; + if exit_code != 0 { + log::warn!( + "error: script '{}' in '{}' failed with exit code {}", + script_name, + package.id.nv, + exit_code, + ); + failed_packages.push(&package.id.nv); + // assume if earlier script fails, later ones will fail too + break; + } + } + } + self.strategy.did_run_scripts(package)?; + } + } + if failed_packages.is_empty() { + Ok(()) + } else { + Err(AnyError::msg(format!( + "failed to run scripts for packages: {}", + failed_packages + .iter() + .map(|p| p.to_string()) + .collect::<Vec<_>>() + .join(", ") + ))) + } + } +} + +// take in all (non copy) packages from snapshot, +// and resolve the set of available binaries to create +// custom commands available to the task runner +fn resolve_baseline_custom_commands( + snapshot: &NpmResolutionSnapshot, + packages: &[NpmResolutionPackage], + get_package_path: impl Fn(&NpmResolutionPackage) -> PathBuf, +) -> Result<crate::task_runner::TaskCustomCommands, AnyError> { + let mut custom_commands = crate::task_runner::TaskCustomCommands::new(); + custom_commands + .insert("npx".to_string(), Rc::new(crate::task_runner::NpxCommand)); + + custom_commands + .insert("npm".to_string(), Rc::new(crate::task_runner::NpmCommand)); + + custom_commands + .insert("node".to_string(), Rc::new(crate::task_runner::NodeCommand)); + + custom_commands.insert( + "node-gyp".to_string(), + Rc::new(crate::task_runner::NodeGypCommand), + ); + + // TODO: this recreates the bin entries which could be redoing some work, but the ones + // we compute earlier in `sync_resolution_with_fs` may not be exhaustive (because we skip + // doing it for packages that are set up already. + // realistically, scripts won't be run very often so it probably isn't too big of an issue. + resolve_custom_commands_from_packages( + custom_commands, + snapshot, + packages, + get_package_path, + ) +} + +// resolves the custom commands from an iterator of packages +// and adds them to the existing custom commands. +// note that this will overwrite any existing custom commands +fn resolve_custom_commands_from_packages< + 'a, + P: IntoIterator<Item = &'a NpmResolutionPackage>, +>( + mut commands: crate::task_runner::TaskCustomCommands, + snapshot: &'a NpmResolutionSnapshot, + packages: P, + get_package_path: impl Fn(&'a NpmResolutionPackage) -> PathBuf, +) -> Result<crate::task_runner::TaskCustomCommands, AnyError> { + let mut bin_entries = BinEntries::new(); + for package in packages { + let package_path = get_package_path(package); + + if package.bin.is_some() { + bin_entries.add(package, package_path); + } + } + let bins = bin_entries.into_bin_files(snapshot); + for (bin_name, script_path) in bins { + commands.insert( + bin_name.clone(), + Rc::new(crate::task_runner::NodeModulesFileRunCommand { + command_name: bin_name, + path: script_path, + }), + ); + } + + Ok(commands) +} + +// resolves the custom commands from the dependencies of a package +// and adds them to the existing custom commands. +// note that this will overwrite any existing custom commands. +fn resolve_custom_commands_from_deps( + baseline: crate::task_runner::TaskCustomCommands, + package: &NpmResolutionPackage, + snapshot: &NpmResolutionSnapshot, + get_package_path: impl Fn(&NpmResolutionPackage) -> PathBuf, +) -> Result<crate::task_runner::TaskCustomCommands, AnyError> { + resolve_custom_commands_from_packages( + baseline, + snapshot, + package + .dependencies + .values() + .map(|id| snapshot.package_from_id(id).unwrap()), + get_package_path, + ) +} |