summaryrefslogtreecommitdiff
path: root/cli/npm/managed/resolvers/common
diff options
context:
space:
mode:
authorNathan Whitaker <17734409+nathanwhit@users.noreply.github.com>2024-09-24 12:23:57 -0700
committerGitHub <noreply@github.com>2024-09-24 19:23:57 +0000
commit36ebc03f177cc7db5deb93f4d403cafbed756eb5 (patch)
treec36af6c9b7093d3191de3cd6e60c4ce9dca4151a /cli/npm/managed/resolvers/common
parentba5b8d0213cde2585236098b00beb8a512889626 (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.rs328
-rw-r--r--cli/npm/managed/resolvers/common/lifecycle_scripts.rs306
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,
+ )
+}