// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. use super::bin_entries::BinEntries; use crate::args::LifecycleScriptsConfig; use crate::task_runner::TaskStdio; use crate::util::progress_bar::ProgressBar; use deno_core::anyhow::Context; use deno_npm::resolution::NpmResolutionSnapshot; use deno_runtime::deno_io::FromRawIoHandle; use deno_semver::package::PackageNv; use deno_semver::Version; use deno_task_shell::KillSignal; use std::borrow::Cow; use std::collections::HashSet; 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, } impl<'a> LifecycleScripts<'a> { pub fn new( config: &'a LifecycleScriptsConfig, strategy: T, ) -> Self { Self { config, packages_with_scripts: Vec::new(), packages_with_scripts_not_run: Vec::new(), strategy: Box::new(strategy), } } } pub 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> { pub 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, } } pub fn has_run_scripts(&self, package: &NpmResolutionPackage) -> bool { self.strategy.has_run(package) } /// 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, ) { if has_lifecycle_scripts(package, &package_path) { if self.can_run_scripts(&package.id.nv) { if !self.has_run_scripts(package) { self .packages_with_scripts .push((package, package_path.into_owned())); } } else if !self.has_run_scripts(package) && (self.config.explicit_install || !self.strategy.has_warned(package)) { // Skip adding `esbuild` as it is known that it can work properly without lifecycle script // being run, and it's also very popular - any project using Vite would raise warnings. { let nv = &package.id.nv; if nv.name == "esbuild" && nv.version >= Version::parse_standard("0.18.0").unwrap() { return; } } 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: &Path, progress_bar: &ProgressBar, ) -> Result<(), AnyError> { let kill_signal = KillSignal::default(); let _drop_signal = kill_signal.clone().drop_guard(); // we don't run with signals forwarded because once signals // are setup then they're process wide. self .finish_with_cancellation( snapshot, packages, root_node_modules_dir_path, progress_bar, kill_signal, ) .await } async fn finish_with_cancellation( self, snapshot: &NpmResolutionSnapshot, packages: &[NpmResolutionPackage], root_node_modules_dir_path: &Path, progress_bar: &ProgressBar, kill_signal: KillSignal, ) -> Result<(), AnyError> { self.warn_not_run_scripts()?; let get_package_path = |p: &NpmResolutionPackage| self.strategy.package_path(p); let mut failed_packages = Vec::new(); let mut bin_entries = BinEntries::new(); if !self.packages_with_scripts.is_empty() { let package_ids = self .packages_with_scripts .iter() .map(|(p, _)| &p.id) .collect::>(); // 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( &mut bin_entries, 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(), Some(root_node_modules_dir_path), ); let mut env_vars = crate::task_runner::real_env_vars(); // so the subprocess can detect that it is running as part of a lifecycle script, // and avoid trying to set up node_modules again env_vars.insert( LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR.to_string(), "1".to_string(), ); // we want to pass the current state of npm resolution down to the deno subprocess // (that may be running as part of the script). we do this with an inherited temp file // // SAFETY: we are sharing a single temp file across all of the scripts. the file position // will be shared among these, which is okay since we run only one script at a time. // However, if we concurrently run scripts in the future we will // have to have multiple temp files. let temp_file_fd = deno_runtime::ops::process::npm_process_state_tempfile( process_state.as_bytes(), ).context("failed to create npm process state tempfile for running lifecycle scripts")?; // SAFETY: fd/handle is valid let _temp_file = unsafe { std::fs::File::from_raw_io_handle(temp_file_fd) }; // make sure the file gets closed env_vars.insert( deno_runtime::ops::process::NPM_RESOLUTION_STATE_FD_ENV_VAR_NAME .to_string(), (temp_file_fd as usize).to_string(), ); 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 _guard = progress_bar.update_with_prompt( crate::util::progress_bar::ProgressMessagePrompt::Initialize, &format!("{}: running '{script_name}' script", package.id.nv), ); let crate::task_runner::TaskResult { exit_code, stderr, stdout, } = 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: Some(root_node_modules_dir_path), stdio: Some(crate::task_runner::TaskIo { stderr: TaskStdio::piped(), stdout: TaskStdio::piped(), }), kill_signal: kill_signal.clone(), }, ) .await?; let stdout = stdout.unwrap(); let stderr = stderr.unwrap(); if exit_code != 0 { log::warn!( "error: script '{}' in '{}' failed with exit code {}{}{}", script_name, package.id.nv, exit_code, if !stdout.trim_ascii().is_empty() { format!( "\nstdout:\n{}\n", String::from_utf8_lossy(&stdout).trim() ) } else { String::new() }, if !stderr.trim_ascii().is_empty() { format!( "\nstderr:\n{}\n", String::from_utf8_lossy(&stderr).trim() ) } else { String::new() }, ); failed_packages.push(&package.id.nv); // assume if earlier script fails, later ones will fail too break; } } } self.strategy.did_run_scripts(package)?; } // re-set up bin entries for the packages which we've run scripts for. // lifecycle scripts can create files that are linked to by bin entries, // and the only reliable way to handle this is to re-link bin entries // (this is what PNPM does as well) bin_entries.finish_only( snapshot, &root_node_modules_dir_path.join(".bin"), |outcome| outcome.warn_if_failed(), &package_ids, )?; } 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::>() .join(", ") ))) } } } const LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR: &str = "DENO_INTERNAL_IS_LIFECYCLE_SCRIPT"; pub fn is_running_lifecycle_script() -> bool { std::env::var(LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR).is_ok() } // 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<'a>( bin_entries: &mut BinEntries<'a>, snapshot: &'a NpmResolutionSnapshot, packages: &'a [NpmResolutionPackage], get_package_path: impl Fn(&NpmResolutionPackage) -> PathBuf, ) -> Result { 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( bin_entries, 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, >( bin_entries: &mut BinEntries<'a>, mut commands: crate::task_runner::TaskCustomCommands, snapshot: &'a NpmResolutionSnapshot, packages: P, get_package_path: impl Fn(&'a NpmResolutionPackage) -> PathBuf, ) -> Result { for package in packages { let package_path = get_package_path(package); if package.bin.is_some() { bin_entries.add(package, package_path); } } let bins: Vec<(String, PathBuf)> = bin_entries.collect_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 { let mut bin_entries = BinEntries::new(); resolve_custom_commands_from_packages( &mut bin_entries, baseline, snapshot, package .dependencies .values() .map(|id| snapshot.package_from_id(id).unwrap()), get_package_path, ) }