diff options
Diffstat (limited to 'cli')
-rw-r--r-- | cli/Cargo.toml | 1 | ||||
-rw-r--r-- | cli/args/flags.rs | 98 | ||||
-rw-r--r-- | cli/args/mod.rs | 14 | ||||
-rw-r--r-- | cli/factory.rs | 3 | ||||
-rw-r--r-- | cli/lsp/resolver.rs | 1 | ||||
-rw-r--r-- | cli/main.rs | 1 | ||||
-rw-r--r-- | cli/mainrt.rs | 1 | ||||
-rw-r--r-- | cli/npm/managed/mod.rs | 12 | ||||
-rw-r--r-- | cli/npm/managed/resolvers/local.rs | 220 | ||||
-rw-r--r-- | cli/npm/managed/resolvers/local/bin_entries.rs | 76 | ||||
-rw-r--r-- | cli/npm/managed/resolvers/mod.rs | 3 | ||||
-rw-r--r-- | cli/standalone/mod.rs | 2 | ||||
-rw-r--r-- | cli/task_runner.rs | 506 | ||||
-rw-r--r-- | cli/tools/task.rs | 490 |
14 files changed, 963 insertions, 465 deletions
diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 0452ac0de..6462d30e0 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -148,6 +148,7 @@ tower-lsp.workspace = true twox-hash.workspace = true typed-arena = "=2.0.1" uuid = { workspace = true, features = ["serde"] } +which.workspace = true zeromq.workspace = true zstd.workspace = true diff --git a/cli/args/flags.rs b/cli/args/flags.rs index b07f3783a..48cfb9240 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -507,6 +507,30 @@ pub enum CaData { Bytes(Vec<u8>), } +// Info needed to run NPM lifecycle scripts +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct LifecycleScriptsConfig { + pub allowed: PackagesAllowedScripts, + pub initial_cwd: Option<PathBuf>, +} + +#[derive(Debug, Clone, Eq, PartialEq, Default)] +/// The set of npm packages that are allowed to run lifecycle scripts. +pub enum PackagesAllowedScripts { + All, + Some(Vec<String>), + #[default] + None, +} + +fn parse_packages_allowed_scripts(s: &str) -> Result<String, AnyError> { + if !s.starts_with("npm:") { + bail!("Invalid package for --allow-scripts: '{}'. An 'npm:' specifier is required", s); + } else { + Ok(s.into()) + } +} + #[derive( Clone, Default, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, )] @@ -562,6 +586,7 @@ pub struct Flags { pub v8_flags: Vec<String>, pub code_cache_enabled: bool, pub permissions: PermissionFlags, + pub allow_scripts: PackagesAllowedScripts, } #[derive(Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize)] @@ -1502,6 +1527,7 @@ Future runs of this module will trigger no downloads or compilation unless .value_hint(ValueHint::FilePath), ) .arg(frozen_lockfile_arg()) + .arg(allow_scripts_arg()) }) } @@ -2213,7 +2239,7 @@ The installation root is determined, in order of precedence: These must be added to the path manually if required.") .defer(|cmd| { - let cmd = runtime_args(cmd, true, true).arg(check_arg(true)); + let cmd = runtime_args(cmd, true, true).arg(check_arg(true)).arg(allow_scripts_arg()); install_args(cmd, true) }) } @@ -3728,6 +3754,28 @@ fn unsafely_ignore_certificate_errors_arg() -> Arg { .value_parser(flags_net::validator) } +fn allow_scripts_arg() -> Arg { + Arg::new("allow-scripts") + .long("allow-scripts") + .num_args(0..) + .use_value_delimiter(true) + .require_equals(true) + .value_name("PACKAGE") + .value_parser(parse_packages_allowed_scripts) + .help("Allow running npm lifecycle scripts for the given packages. Note: Scripts will only be executed when using a node_modules directory (`--node-modules-dir`)") +} + +fn allow_scripts_arg_parse(flags: &mut Flags, matches: &mut ArgMatches) { + let Some(parts) = matches.remove_many::<String>("allow-scripts") else { + return; + }; + if parts.len() == 0 { + flags.allow_scripts = PackagesAllowedScripts::All; + } else { + flags.allow_scripts = PackagesAllowedScripts::Some(parts.collect()); + } +} + fn add_parse(flags: &mut Flags, matches: &mut ArgMatches) { flags.subcommand = DenoSubcommand::Add(add_parse_inner(matches, None)); } @@ -3810,6 +3858,7 @@ fn bundle_parse(flags: &mut Flags, matches: &mut ArgMatches) { fn cache_parse(flags: &mut Flags, matches: &mut ArgMatches) { compile_args_parse(flags, matches); frozen_lockfile_arg_parse(flags, matches); + allow_scripts_arg_parse(flags, matches); let files = matches.remove_many::<String>("file").unwrap().collect(); flags.subcommand = DenoSubcommand::Cache(CacheFlags { files }); } @@ -4096,6 +4145,7 @@ fn install_parse(flags: &mut Flags, matches: &mut ArgMatches) { let local_flags = matches .remove_many("cmd") .map(|packages| add_parse_inner(matches, Some(packages))); + allow_scripts_arg_parse(flags, matches); flags.subcommand = DenoSubcommand::Install(InstallFlags { global, kind: InstallKind::Local(local_flags), @@ -9969,4 +10019,50 @@ mod tests { ); } } + + #[test] + fn allow_scripts() { + let cases = [ + (Some("--allow-scripts"), Ok(PackagesAllowedScripts::All)), + (None, Ok(PackagesAllowedScripts::None)), + ( + Some("--allow-scripts=npm:foo"), + Ok(PackagesAllowedScripts::Some(svec!["npm:foo"])), + ), + ( + Some("--allow-scripts=npm:foo,npm:bar"), + Ok(PackagesAllowedScripts::Some(svec!["npm:foo", "npm:bar"])), + ), + (Some("--allow-scripts=foo"), Err("Invalid package")), + ]; + for (flag, value) in cases { + let mut args = svec!["deno", "cache"]; + if let Some(flag) = flag { + args.push(flag.into()); + } + args.push("script.ts".into()); + let r = flags_from_vec(args); + match value { + Ok(value) => { + assert_eq!( + r.unwrap(), + Flags { + subcommand: DenoSubcommand::Cache(CacheFlags { + files: svec!["script.ts"], + }), + allow_scripts: value, + ..Flags::default() + } + ); + } + Err(e) => { + let err = r.unwrap_err(); + assert!( + err.to_string().contains(e), + "expected to contain '{e}' got '{err}'" + ); + } + } + } + } } diff --git a/cli/args/mod.rs b/cli/args/mod.rs index 553af51a1..e048b332a 100644 --- a/cli/args/mod.rs +++ b/cli/args/mod.rs @@ -1720,6 +1720,20 @@ impl CliOptions { } full_paths } + + pub fn lifecycle_scripts_config(&self) -> LifecycleScriptsConfig { + LifecycleScriptsConfig { + allowed: self.flags.allow_scripts.clone(), + initial_cwd: if matches!( + self.flags.allow_scripts, + PackagesAllowedScripts::None + ) { + None + } else { + Some(self.initial_cwd.clone()) + }, + } + } } /// Resolves the path to use for a local node_modules folder. diff --git a/cli/factory.rs b/cli/factory.rs index 5b066c67f..e3147e49f 100644 --- a/cli/factory.rs +++ b/cli/factory.rs @@ -443,7 +443,8 @@ impl CliFactory { &self.options.workspace, )), npm_system_info: self.options.npm_system_info(), - npmrc: self.options.npmrc().clone() + npmrc: self.options.npmrc().clone(), + lifecycle_scripts: self.options.lifecycle_scripts_config(), }) }).await }.boxed_local()) diff --git a/cli/lsp/resolver.rs b/cli/lsp/resolver.rs index 29f986ce3..d6414697b 100644 --- a/cli/lsp/resolver.rs +++ b/cli/lsp/resolver.rs @@ -469,6 +469,7 @@ async fn create_npm_resolver( .and_then(|d| d.npmrc.clone()) .unwrap_or_else(create_default_npmrc), npm_system_info: NpmSystemInfo::default(), + lifecycle_scripts: Default::default(), }) }; Some(create_cli_npm_resolver_for_lsp(options).await) diff --git a/cli/main.rs b/cli/main.rs index 8ae83e735..4264e1610 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -21,6 +21,7 @@ mod npm; mod ops; mod resolver; mod standalone; +mod task_runner; mod tools; mod tsc; mod util; diff --git a/cli/mainrt.rs b/cli/mainrt.rs index d4f0f558e..aafbf7932 100644 --- a/cli/mainrt.rs +++ b/cli/mainrt.rs @@ -18,6 +18,7 @@ mod js; mod node; mod npm; mod resolver; +mod task_runner; mod util; mod version; mod worker; diff --git a/cli/npm/managed/mod.rs b/cli/npm/managed/mod.rs index 6022396d6..76645d1d6 100644 --- a/cli/npm/managed/mod.rs +++ b/cli/npm/managed/mod.rs @@ -29,6 +29,7 @@ use deno_semver::package::PackageReq; use resolution::AddPkgReqsResult; use crate::args::CliLockfile; +use crate::args::LifecycleScriptsConfig; use crate::args::NpmProcessState; use crate::args::NpmProcessStateKind; use crate::args::PackageJsonInstallDepsProvider; @@ -70,6 +71,7 @@ pub struct CliNpmResolverManagedCreateOptions { pub npm_system_info: NpmSystemInfo, pub package_json_deps_provider: Arc<PackageJsonInstallDepsProvider>, pub npmrc: Arc<ResolvedNpmRc>, + pub lifecycle_scripts: LifecycleScriptsConfig, } pub async fn create_managed_npm_resolver_for_lsp( @@ -98,6 +100,7 @@ pub async fn create_managed_npm_resolver_for_lsp( options.maybe_node_modules_path, options.npm_system_info, snapshot, + options.lifecycle_scripts, ) }) .await @@ -122,6 +125,7 @@ pub async fn create_managed_npm_resolver( options.maybe_node_modules_path, options.npm_system_info, snapshot, + options.lifecycle_scripts, )) } @@ -138,6 +142,7 @@ fn create_inner( node_modules_dir_path: Option<PathBuf>, npm_system_info: NpmSystemInfo, snapshot: Option<ValidSerializedNpmResolutionSnapshot>, + lifecycle_scripts: LifecycleScriptsConfig, ) -> Arc<dyn CliNpmResolver> { let resolution = Arc::new(NpmResolution::from_serialized( npm_api.clone(), @@ -160,6 +165,7 @@ fn create_inner( tarball_cache.clone(), node_modules_dir_path, npm_system_info.clone(), + lifecycle_scripts.clone(), ); Arc::new(ManagedCliNpmResolver::new( fs, @@ -172,6 +178,7 @@ fn create_inner( tarball_cache, text_only_progress_bar, npm_system_info, + lifecycle_scripts, )) } @@ -258,6 +265,7 @@ pub struct ManagedCliNpmResolver { text_only_progress_bar: ProgressBar, npm_system_info: NpmSystemInfo, top_level_install_flag: AtomicFlag, + lifecycle_scripts: LifecycleScriptsConfig, } impl std::fmt::Debug for ManagedCliNpmResolver { @@ -281,6 +289,7 @@ impl ManagedCliNpmResolver { tarball_cache: Arc<TarballCache>, text_only_progress_bar: ProgressBar, npm_system_info: NpmSystemInfo, + lifecycle_scripts: LifecycleScriptsConfig, ) -> Self { Self { fs, @@ -294,6 +303,7 @@ impl ManagedCliNpmResolver { tarball_cache, npm_system_info, top_level_install_flag: Default::default(), + lifecycle_scripts, } } @@ -578,6 +588,7 @@ impl CliNpmResolver for ManagedCliNpmResolver { self.tarball_cache.clone(), self.root_node_modules_path().map(ToOwned::to_owned), self.npm_system_info.clone(), + self.lifecycle_scripts.clone(), ), self.maybe_lockfile.clone(), self.npm_api.clone(), @@ -587,6 +598,7 @@ impl CliNpmResolver for ManagedCliNpmResolver { self.tarball_cache.clone(), self.text_only_progress_bar.clone(), self.npm_system_info.clone(), + self.lifecycle_scripts.clone(), )) } diff --git a/cli/npm/managed/resolvers/local.rs b/cli/npm/managed/resolvers/local.rs index 1f8e82d54..913cf986d 100644 --- a/cli/npm/managed/resolvers/local.rs +++ b/cli/npm/managed/resolvers/local.rs @@ -16,8 +16,11 @@ use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; +use crate::args::LifecycleScriptsConfig; +use crate::args::PackagesAllowedScripts; use async_trait::async_trait; use deno_ast::ModuleSpecifier; +use deno_core::anyhow; use deno_core::anyhow::Context; use deno_core::error::AnyError; use deno_core::futures::stream::FuturesUnordered; @@ -68,6 +71,7 @@ pub struct LocalNpmPackageResolver { root_node_modules_url: Url, system_info: NpmSystemInfo, registry_read_permission_checker: RegistryReadPermissionChecker, + lifecycle_scripts: LifecycleScriptsConfig, } impl LocalNpmPackageResolver { @@ -81,6 +85,7 @@ impl LocalNpmPackageResolver { tarball_cache: Arc<TarballCache>, node_modules_folder: PathBuf, system_info: NpmSystemInfo, + lifecycle_scripts: LifecycleScriptsConfig, ) -> Self { Self { cache, @@ -97,6 +102,7 @@ impl LocalNpmPackageResolver { .unwrap(), root_node_modules_path: node_modules_folder, system_info, + lifecycle_scripts, } } @@ -245,6 +251,7 @@ impl NpmPackageFsResolver for LocalNpmPackageResolver { &self.tarball_cache, &self.root_node_modules_path, &self.system_info, + &self.lifecycle_scripts, ) .await } @@ -260,7 +267,131 @@ impl NpmPackageFsResolver for LocalNpmPackageResolver { } } +// 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], + local_registry_dir: &Path, +) -> 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, + local_registry_dir, + ) +} + +// 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, + local_registry_dir: &Path, +) -> Result<crate::task_runner::TaskCustomCommands, AnyError> { + let mut bin_entries = bin_entries::BinEntries::new(); + for package in packages { + let package_path = + local_node_modules_package_path(local_registry_dir, package); + + if package.bin.is_some() { + bin_entries.add(package.clone(), 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) +} + +fn local_node_modules_package_path( + local_registry_dir: &Path, + package: &NpmResolutionPackage, +) -> PathBuf { + local_registry_dir + .join(get_package_folder_id_folder_name( + &package.get_package_cache_folder_id(), + )) + .join("node_modules") + .join(&package.id.nv.name) +} + +// 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, + local_registry_dir: &Path, +) -> Result<crate::task_runner::TaskCustomCommands, AnyError> { + resolve_custom_commands_from_packages( + baseline, + snapshot, + package + .dependencies + .values() + .map(|id| snapshot.package_from_id(id).unwrap()), + local_registry_dir, + ) +} + +fn can_run_scripts( + allow_scripts: &PackagesAllowedScripts, + package_nv: &PackageNv, +) -> bool { + match allow_scripts { + 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, + } +} + +fn has_lifecycle_scripts(package: &NpmResolutionPackage) -> bool { + package.scripts.contains_key("preinstall") + || package.scripts.contains_key("install") + || package.scripts.contains_key("postinstall") +} + /// Creates a pnpm style folder structure. +#[allow(clippy::too_many_arguments)] async fn sync_resolution_with_fs( snapshot: &NpmResolutionSnapshot, cache: &Arc<NpmCache>, @@ -269,6 +400,7 @@ async fn sync_resolution_with_fs( tarball_cache: &Arc<TarballCache>, root_node_modules_dir_path: &Path, system_info: &NpmSystemInfo, + lifecycle_scripts: &LifecycleScriptsConfig, ) -> Result<(), AnyError> { if snapshot.is_empty() && pkg_json_deps_provider.workspace_pkgs().is_empty() { return Ok(()); // don't create the directory @@ -307,6 +439,8 @@ async fn sync_resolution_with_fs( let mut newest_packages_by_name: HashMap<&String, &NpmResolutionPackage> = HashMap::with_capacity(package_partitions.packages.len()); let bin_entries = Rc::new(RefCell::new(bin_entries::BinEntries::new())); + let mut packages_with_scripts = Vec::with_capacity(2); + let mut packages_with_scripts_not_run = Vec::new(); for package in &package_partitions.packages { if let Some(current_pkg) = newest_packages_by_name.get_mut(&package.id.nv.name) @@ -331,6 +465,7 @@ async fn sync_resolution_with_fs( // are forced to be recreated setup_cache.remove_dep(&package_folder_name); + let folder_path = folder_path.clone(); let bin_entries_to_setup = bin_entries.clone(); cache_futures.push(async move { tarball_cache @@ -368,6 +503,24 @@ async fn sync_resolution_with_fs( Ok::<_, AnyError>(()) }); } + + if has_lifecycle_scripts(package) { + let scripts_run = folder_path.join(".scripts-run"); + if can_run_scripts(&lifecycle_scripts.allowed, &package.id.nv) { + if !scripts_run.exists() { + let sub_node_modules = folder_path.join("node_modules"); + let package_path = + join_package_name(&sub_node_modules, &package.id.nv.name); + packages_with_scripts.push(( + package.clone(), + package_path, + scripts_run, + )); + } + } else if !scripts_run.exists() { + packages_with_scripts_not_run.push(package.id.nv.clone()); + } + } } while let Some(result) = cache_futures.next().await { @@ -509,6 +662,73 @@ async fn sync_resolution_with_fs( } } + if !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, + &package_partitions.packages, + &deno_local_registry_dir, + )?; + let init_cwd = lifecycle_scripts.initial_cwd.as_deref().unwrap(); + + for (package, package_path, scripts_run_path) in 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, + &deno_local_registry_dir, + )?; + for script_name in ["preinstall", "install", "postinstall"] { + if let Some(script) = package.scripts.get(script_name) { + let exit_code = + crate::task_runner::run_task(crate::task_runner::RunTaskOptions { + task_name: script_name, + script, + cwd: &package_path, + env_vars: crate::task_runner::real_env_vars(), + custom_commands: custom_commands.clone(), + init_cwd, + argv: &[], + root_node_modules_dir: Some(root_node_modules_dir_path), + }) + .await?; + if exit_code != 0 { + anyhow::bail!( + "script '{}' in '{}' failed with exit code {}", + script_name, + package.id.nv, + exit_code, + ); + } + } + } + fs::write(scripts_run_path, "")?; + } + } + + if !packages_with_scripts_not_run.is_empty() { + let (maybe_install, maybe_install_example) = if *crate::args::DENO_FUTURE { + ( + " or `deno install`", + " or `deno install --allow-scripts=pkg1,pkg2`", + ) + } else { + ("", "") + }; + let packages = packages_with_scripts_not_run + .iter() + .map(|p| p.to_string()) + .collect::<Vec<_>>() + .join(", "); + log::warn!("{}: Packages contained npm lifecycle scripts (preinstall/install/postinstall) that were not executed. + This may cause the packages to not work correctly. To run them, use the `--allow-scripts` flag with `deno cache`{maybe_install} + (e.g. `deno cache --allow-scripts=pkg1,pkg2 <entrypoint>`{maybe_install_example}):\n {packages}", crate::colors::yellow("warning")); + } + setup_cache.save(); drop(single_process_lock); drop(pb_clear_guard); diff --git a/cli/npm/managed/resolvers/local/bin_entries.rs b/cli/npm/managed/resolvers/local/bin_entries.rs index 4a3c5ce4f..980a2653b 100644 --- a/cli/npm/managed/resolvers/local/bin_entries.rs +++ b/cli/npm/managed/resolvers/local/bin_entries.rs @@ -71,19 +71,16 @@ impl BinEntries { self.entries.push((package, package_path)); } - /// Finish setting up the bin entries, writing the necessary files - /// to disk. - pub(super) fn finish( - mut self, + fn for_each_entry( + &mut self, snapshot: &NpmResolutionSnapshot, - bin_node_modules_dir_path: &Path, + mut f: impl FnMut( + &NpmResolutionPackage, + &Path, + &str, // bin name + &str, // bin script + ) -> Result<(), AnyError>, ) -> 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()), - )?; - } - 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 @@ -101,13 +98,7 @@ impl BinEntries { // we already set up a bin entry with this name continue; } - set_up_bin_entry( - package, - name, - script, - package_path, - bin_node_modules_dir_path, - )?; + f(package, package_path, name, script)?; } deno_npm::registry::NpmPackageVersionBinEntry::Map(entries) => { for (name, script) in entries { @@ -115,13 +106,7 @@ impl BinEntries { // we already set up a bin entry with this name continue; } - set_up_bin_entry( - package, - name, - script, - package_path, - bin_node_modules_dir_path, - )?; + f(package, package_path, name, script)?; } } } @@ -130,6 +115,47 @@ impl BinEntries { Ok(()) } + + /// Collect the bin entries into a vec of (name, script path) + pub(super) 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(super) 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 diff --git a/cli/npm/managed/resolvers/mod.rs b/cli/npm/managed/resolvers/mod.rs index a7f545916..2dfc323e9 100644 --- a/cli/npm/managed/resolvers/mod.rs +++ b/cli/npm/managed/resolvers/mod.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use deno_npm::NpmSystemInfo; use deno_runtime::deno_fs::FileSystem; +use crate::args::LifecycleScriptsConfig; use crate::args::PackageJsonInstallDepsProvider; use crate::util::progress_bar::ProgressBar; @@ -32,6 +33,7 @@ pub fn create_npm_fs_resolver( tarball_cache: Arc<TarballCache>, maybe_node_modules_path: Option<PathBuf>, system_info: NpmSystemInfo, + lifecycle_scripts: LifecycleScriptsConfig, ) -> Arc<dyn NpmPackageFsResolver> { match maybe_node_modules_path { Some(node_modules_folder) => Arc::new(LocalNpmPackageResolver::new( @@ -43,6 +45,7 @@ pub fn create_npm_fs_resolver( tarball_cache, node_modules_folder, system_info, + lifecycle_scripts, )), None => Arc::new(GlobalNpmPackageResolver::new( npm_cache, diff --git a/cli/standalone/mod.rs b/cli/standalone/mod.rs index 106b7b7e7..796551729 100644 --- a/cli/standalone/mod.rs +++ b/cli/standalone/mod.rs @@ -478,6 +478,7 @@ pub async fn run( scopes: Default::default(), registry_configs: Default::default(), }), + lifecycle_scripts: Default::default(), }, )) .await?; @@ -522,6 +523,7 @@ pub async fn run( // Packages from different registries are already inlined in the ESZip, // so no need to create actual `.npmrc` configuration. npmrc: create_default_npmrc(), + lifecycle_scripts: Default::default(), }, )) .await?; diff --git a/cli/task_runner.rs b/cli/task_runner.rs new file mode 100644 index 000000000..e8937590d --- /dev/null +++ b/cli/task_runner.rs @@ -0,0 +1,506 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::rc::Rc; + +use deno_ast::MediaType; +use deno_core::anyhow::Context; +use deno_core::error::AnyError; +use deno_core::futures; +use deno_core::futures::future::LocalBoxFuture; +use deno_runtime::deno_node::NodeResolver; +use deno_semver::package::PackageNv; +use deno_task_shell::ExecutableCommand; +use deno_task_shell::ExecuteResult; +use deno_task_shell::ShellCommand; +use deno_task_shell::ShellCommandContext; +use lazy_regex::Lazy; +use regex::Regex; +use tokio::task::LocalSet; + +use crate::npm::CliNpmResolver; +use crate::npm::InnerCliNpmResolverRef; +use crate::npm::ManagedCliNpmResolver; + +pub fn get_script_with_args(script: &str, argv: &[String]) -> String { + let additional_args = argv + .iter() + // surround all the additional arguments in double quotes + // and sanitize any command substitution + .map(|a| format!("\"{}\"", a.replace('"', "\\\"").replace('$', "\\$"))) + .collect::<Vec<_>>() + .join(" "); + let script = format!("{script} {additional_args}"); + script.trim().to_owned() +} + +pub struct RunTaskOptions<'a> { + pub task_name: &'a str, + pub script: &'a str, + pub cwd: &'a Path, + pub init_cwd: &'a Path, + pub env_vars: HashMap<String, String>, + pub argv: &'a [String], + pub custom_commands: HashMap<String, Rc<dyn ShellCommand>>, + pub root_node_modules_dir: Option<&'a Path>, +} + +pub type TaskCustomCommands = HashMap<String, Rc<dyn ShellCommand>>; + +pub async fn run_task(opts: RunTaskOptions<'_>) -> Result<i32, AnyError> { + let script = get_script_with_args(opts.script, opts.argv); + let seq_list = deno_task_shell::parser::parse(&script) + .with_context(|| format!("Error parsing script '{}'.", opts.task_name))?; + let env_vars = + prepare_env_vars(opts.env_vars, opts.init_cwd, opts.root_node_modules_dir); + let local = LocalSet::new(); + let future = deno_task_shell::execute( + seq_list, + env_vars, + opts.cwd, + opts.custom_commands, + ); + Ok(local.run_until(future).await) +} + +fn prepare_env_vars( + mut env_vars: HashMap<String, String>, + initial_cwd: &Path, + node_modules_dir: Option<&Path>, +) -> HashMap<String, String> { + const INIT_CWD_NAME: &str = "INIT_CWD"; + if !env_vars.contains_key(INIT_CWD_NAME) { + // if not set, set an INIT_CWD env var that has the cwd + env_vars.insert( + INIT_CWD_NAME.to_string(), + initial_cwd.to_string_lossy().to_string(), + ); + } + if let Some(node_modules_dir) = node_modules_dir { + prepend_to_path( + &mut env_vars, + node_modules_dir.join(".bin").to_string_lossy().to_string(), + ); + } + env_vars +} + +fn prepend_to_path(env_vars: &mut HashMap<String, String>, value: String) { + match env_vars.get_mut("PATH") { + Some(path) => { + if path.is_empty() { + *path = value; + } else { + *path = + format!("{}{}{}", value, if cfg!(windows) { ";" } else { ":" }, path); + } + } + None => { + env_vars.insert("PATH".to_string(), value); + } + } +} + +pub fn real_env_vars() -> HashMap<String, String> { + std::env::vars() + .map(|(k, v)| { + if cfg!(windows) { + (k.to_uppercase(), v) + } else { + (k, v) + } + }) + .collect::<HashMap<String, String>>() +} + +// WARNING: Do not depend on this env var in user code. It's not stable API. +pub(crate) const USE_PKG_JSON_HIDDEN_ENV_VAR_NAME: &str = + "DENO_INTERNAL_TASK_USE_PKG_JSON"; + +pub struct NpmCommand; + +impl ShellCommand for NpmCommand { + fn execute( + &self, + mut context: ShellCommandContext, + ) -> LocalBoxFuture<'static, ExecuteResult> { + if context.args.first().map(|s| s.as_str()) == Some("run") + && context.args.len() > 2 + // for now, don't run any npm scripts that have a flag because + // we don't handle stuff like `--workspaces` properly + && !context.args.iter().any(|s| s.starts_with('-')) + { + // run with deno task instead + let mut args = Vec::with_capacity(context.args.len()); + args.push("task".to_string()); + args.extend(context.args.iter().skip(1).cloned()); + + let mut state = context.state; + state.apply_env_var(USE_PKG_JSON_HIDDEN_ENV_VAR_NAME, "1"); + return ExecutableCommand::new( + "deno".to_string(), + std::env::current_exe().unwrap(), + ) + .execute(ShellCommandContext { + args, + state, + ..context + }); + } + + // fallback to running the real npm command + let npm_path = match context.state.resolve_command_path("npm") { + Ok(path) => path, + Err(err) => { + let _ = context.stderr.write_line(&format!("{}", err)); + return Box::pin(futures::future::ready( + ExecuteResult::from_exit_code(err.exit_code()), + )); + } + }; + ExecutableCommand::new("npm".to_string(), npm_path).execute(context) + } +} + +pub struct NodeCommand; + +impl ShellCommand for NodeCommand { + fn execute( + &self, + context: ShellCommandContext, + ) -> LocalBoxFuture<'static, ExecuteResult> { + // run with deno if it's a simple invocation, fall back to node + // if there are extra flags + let mut args = Vec::with_capacity(context.args.len()); + if context.args.len() > 1 + && ( + context.args[0].starts_with('-') // has a flag + || !matches!( + MediaType::from_str(&context.args[0]), + MediaType::Cjs | MediaType::Mjs | MediaType::JavaScript + ) + // not a script file + ) + { + return ExecutableCommand::new( + "node".to_string(), + "node".to_string().into(), + ) + .execute(context); + } + args.extend(["run", "-A"].into_iter().map(|s| s.to_string())); + args.extend(context.args.iter().cloned()); + + let mut state = context.state; + state.apply_env_var(USE_PKG_JSON_HIDDEN_ENV_VAR_NAME, "1"); + ExecutableCommand::new("deno".to_string(), std::env::current_exe().unwrap()) + .execute(ShellCommandContext { + args, + state, + ..context + }) + } +} + +pub struct NodeGypCommand; + +impl ShellCommand for NodeGypCommand { + fn execute( + &self, + context: ShellCommandContext, + ) -> LocalBoxFuture<'static, ExecuteResult> { + // at the moment this shell command is just to give a warning if node-gyp is not found + // in the future, we could try to run/install node-gyp for the user with deno + if which::which("node-gyp").is_err() { + log::warn!("{}: node-gyp was used in a script, but was not listed as a dependency. Either add it as a dependency or install it globally (e.g. `npm install -g node-gyp`)", crate::colors::yellow("warning")); + } + ExecutableCommand::new( + "node-gyp".to_string(), + "node-gyp".to_string().into(), + ) + .execute(context) + } +} + +pub struct NpxCommand; + +impl ShellCommand for NpxCommand { + fn execute( + &self, + mut context: ShellCommandContext, + ) -> LocalBoxFuture<'static, ExecuteResult> { + if let Some(first_arg) = context.args.first().cloned() { + if let Some(command) = context.state.resolve_custom_command(&first_arg) { + let context = ShellCommandContext { + args: context.args.iter().skip(1).cloned().collect::<Vec<_>>(), + ..context + }; + command.execute(context) + } else { + // can't find the command, so fallback to running the real npx command + let npx_path = match context.state.resolve_command_path("npx") { + Ok(npx) => npx, + Err(err) => { + let _ = context.stderr.write_line(&format!("{}", err)); + return Box::pin(futures::future::ready( + ExecuteResult::from_exit_code(err.exit_code()), + )); + } + }; + ExecutableCommand::new("npx".to_string(), npx_path).execute(context) + } + } else { + let _ = context.stderr.write_line("npx: missing command"); + Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))) + } + } +} + +#[derive(Clone)] +struct NpmPackageBinCommand { + name: String, + npm_package: PackageNv, +} + +impl ShellCommand for NpmPackageBinCommand { + fn execute( + &self, + context: ShellCommandContext, + ) -> LocalBoxFuture<'static, ExecuteResult> { + let mut args = vec![ + "run".to_string(), + "-A".to_string(), + if self.npm_package.name == self.name { + format!("npm:{}", self.npm_package) + } else { + format!("npm:{}/{}", self.npm_package, self.name) + }, + ]; + + args.extend(context.args); + let executable_command = deno_task_shell::ExecutableCommand::new( + "deno".to_string(), + std::env::current_exe().unwrap(), + ); + executable_command.execute(ShellCommandContext { args, ..context }) + } +} + +/// Runs a module in the node_modules folder. +#[derive(Clone)] +pub struct NodeModulesFileRunCommand { + pub command_name: String, + pub path: PathBuf, +} + +impl ShellCommand for NodeModulesFileRunCommand { + fn execute( + &self, + mut context: ShellCommandContext, + ) -> LocalBoxFuture<'static, ExecuteResult> { + let mut args = vec![ + "run".to_string(), + "--ext=js".to_string(), + "-A".to_string(), + self.path.to_string_lossy().to_string(), + ]; + args.extend(context.args); + let executable_command = deno_task_shell::ExecutableCommand::new( + "deno".to_string(), + std::env::current_exe().unwrap(), + ); + // set this environment variable so that the launched process knows the npm command name + context + .state + .apply_env_var("DENO_INTERNAL_NPM_CMD_NAME", &self.command_name); + executable_command.execute(ShellCommandContext { args, ..context }) + } +} + +pub fn resolve_custom_commands( + npm_resolver: &dyn CliNpmResolver, + node_resolver: &NodeResolver, +) -> Result<HashMap<String, Rc<dyn ShellCommand>>, AnyError> { + let mut commands = match npm_resolver.as_inner() { + InnerCliNpmResolverRef::Byonm(npm_resolver) => { + let node_modules_dir = npm_resolver.root_node_modules_path().unwrap(); + resolve_npm_commands_from_bin_dir(node_modules_dir) + } + InnerCliNpmResolverRef::Managed(npm_resolver) => { + resolve_managed_npm_commands(npm_resolver, node_resolver)? + } + }; + commands.insert("npm".to_string(), Rc::new(NpmCommand)); + Ok(commands) +} + +pub fn resolve_npm_commands_from_bin_dir( + node_modules_dir: &Path, +) -> HashMap<String, Rc<dyn ShellCommand>> { + let mut result = HashMap::<String, Rc<dyn ShellCommand>>::new(); + let bin_dir = node_modules_dir.join(".bin"); + log::debug!("Resolving commands in '{}'.", bin_dir.display()); + match std::fs::read_dir(&bin_dir) { + Ok(entries) => { + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + if let Some(command) = resolve_bin_dir_entry_command(entry) { + result.insert(command.command_name.clone(), Rc::new(command)); + } + } + } + Err(err) => { + log::debug!("Failed read_dir for '{}': {:#}", bin_dir.display(), err); + } + } + result +} + +fn resolve_bin_dir_entry_command( + entry: std::fs::DirEntry, +) -> Option<NodeModulesFileRunCommand> { + if entry.path().extension().is_some() { + return None; // only look at files without extensions (even on Windows) + } + let file_type = entry.file_type().ok()?; + let path = if file_type.is_file() { + entry.path() + } else if file_type.is_symlink() { + entry.path().canonicalize().ok()? + } else { + return None; + }; + let text = std::fs::read_to_string(&path).ok()?; + let command_name = entry.file_name().to_string_lossy().to_string(); + if let Some(path) = resolve_execution_path_from_npx_shim(path, &text) { + log::debug!( + "Resolved npx command '{}' to '{}'.", + command_name, + path.display() + ); + Some(NodeModulesFileRunCommand { command_name, path }) + } else { + log::debug!("Failed resolving npx command '{}'.", command_name); + None + } +} + +/// This is not ideal, but it works ok because it allows us to bypass +/// the shebang and execute the script directly with Deno. +fn resolve_execution_path_from_npx_shim( + file_path: PathBuf, + text: &str, +) -> Option<PathBuf> { + static SCRIPT_PATH_RE: Lazy<Regex> = + lazy_regex::lazy_regex!(r#""\$basedir\/([^"]+)" "\$@""#); + + if text.starts_with("#!/usr/bin/env node") { + // launch this file itself because it's a JS file + Some(file_path) + } else { + // Search for... + // > "$basedir/../next/dist/bin/next" "$@" + // ...which is what it will look like on Windows + SCRIPT_PATH_RE + .captures(text) + .and_then(|c| c.get(1)) + .map(|relative_path| { + file_path.parent().unwrap().join(relative_path.as_str()) + }) + } +} + +fn resolve_managed_npm_commands( + npm_resolver: &ManagedCliNpmResolver, + node_resolver: &NodeResolver, +) -> Result<HashMap<String, Rc<dyn ShellCommand>>, AnyError> { + let mut result = HashMap::new(); + let snapshot = npm_resolver.snapshot(); + for id in snapshot.top_level_packages() { + let package_folder = npm_resolver.resolve_pkg_folder_from_pkg_id(id)?; + let bin_commands = + node_resolver.resolve_binary_commands(&package_folder)?; + for bin_command in bin_commands { + result.insert( + bin_command.to_string(), + Rc::new(NpmPackageBinCommand { + name: bin_command, + npm_package: id.nv.clone(), + }) as Rc<dyn ShellCommand>, + ); + } + } + if !result.contains_key("npx") { + result.insert("npx".to_string(), Rc::new(NpxCommand)); + } + Ok(result) +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn test_prepend_to_path() { + let mut env_vars = HashMap::new(); + + prepend_to_path(&mut env_vars, "/example".to_string()); + assert_eq!( + env_vars, + HashMap::from([("PATH".to_string(), "/example".to_string())]) + ); + + prepend_to_path(&mut env_vars, "/example2".to_string()); + let separator = if cfg!(windows) { ";" } else { ":" }; + assert_eq!( + env_vars, + HashMap::from([( + "PATH".to_string(), + format!("/example2{}/example", separator) + )]) + ); + + env_vars.get_mut("PATH").unwrap().clear(); + prepend_to_path(&mut env_vars, "/example".to_string()); + assert_eq!( + env_vars, + HashMap::from([("PATH".to_string(), "/example".to_string())]) + ); + } + + #[test] + fn test_resolve_execution_path_from_npx_shim() { + // example shim on unix + let unix_shim = r#"#!/usr/bin/env node +"use strict"; +console.log('Hi!'); +"#; + let path = PathBuf::from("/node_modules/.bin/example"); + assert_eq!( + resolve_execution_path_from_npx_shim(path.clone(), unix_shim).unwrap(), + path + ); + // example shim on windows + let windows_shim = r#"#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../example/bin/example" "$@" +else + exec node "$basedir/../example/bin/example" "$@" +fi"#; + assert_eq!( + resolve_execution_path_from_npx_shim(path.clone(), windows_shim).unwrap(), + path.parent().unwrap().join("../example/bin/example") + ); + } +} diff --git a/cli/tools/task.rs b/cli/tools/task.rs index 2905134f4..9ab54f258 100644 --- a/cli/tools/task.rs +++ b/cli/tools/task.rs @@ -1,12 +1,12 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +use crate::args::CliOptions; use crate::args::Flags; use crate::args::TaskFlags; use crate::colors; use crate::factory::CliFactory; use crate::npm::CliNpmResolver; -use crate::npm::InnerCliNpmResolverRef; -use crate::npm::ManagedCliNpmResolver; +use crate::task_runner; use crate::util::fs::canonicalize_path; use deno_config::workspace::TaskOrScript; use deno_config::workspace::Workspace; @@ -14,17 +14,8 @@ use deno_config::workspace::WorkspaceTasksConfig; use deno_core::anyhow::bail; use deno_core::anyhow::Context; use deno_core::error::AnyError; -use deno_core::futures; -use deno_core::futures::future::LocalBoxFuture; use deno_core::normalize_path; -use deno_runtime::deno_node::NodeResolver; -use deno_semver::package::PackageNv; -use deno_task_shell::ExecutableCommand; -use deno_task_shell::ExecuteResult; use deno_task_shell::ShellCommand; -use deno_task_shell::ShellCommandContext; -use lazy_regex::Lazy; -use regex::Regex; use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; @@ -32,11 +23,6 @@ use std::path::Path; use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; -use tokio::task::LocalSet; - -// WARNING: Do not depend on this env var in user code. It's not stable API. -const USE_PKG_JSON_HIDDEN_ENV_VAR_NAME: &str = - "DENO_INTERNAL_TASK_USE_PKG_JSON"; pub async fn execute_script( flags: Flags, @@ -48,13 +34,16 @@ pub async fn execute_script( if !start_ctx.has_deno_or_pkg_json() { bail!("deno task couldn't find deno.json(c). See https://deno.land/manual@v{}/getting_started/configuration_file", env!("CARGO_PKG_VERSION")) } - let force_use_pkg_json = std::env::var_os(USE_PKG_JSON_HIDDEN_ENV_VAR_NAME) - .map(|v| { - // always remove so sub processes don't inherit this env var - std::env::remove_var(USE_PKG_JSON_HIDDEN_ENV_VAR_NAME); - v == "1" - }) - .unwrap_or(false); + let force_use_pkg_json = + std::env::var_os(crate::task_runner::USE_PKG_JSON_HIDDEN_ENV_VAR_NAME) + .map(|v| { + // always remove so sub processes don't inherit this env var + std::env::remove_var( + crate::task_runner::USE_PKG_JSON_HIDDEN_ENV_VAR_NAME, + ); + v == "1" + }) + .unwrap_or(false); let tasks_config = start_ctx.to_tasks_config()?; let tasks_config = if force_use_pkg_json { tasks_config.with_only_pkg_json() @@ -76,7 +65,7 @@ pub async fn execute_script( let npm_resolver = factory.npm_resolver().await?; let node_resolver = factory.node_resolver().await?; - let env_vars = real_env_vars(); + let env_vars = task_runner::real_env_vars(); match tasks_config.task(task_name) { Some((dir_url, task_or_script)) => match task_or_script { @@ -87,19 +76,18 @@ pub async fn execute_script( None => normalize_path(dir_url.to_file_path().unwrap()), }; - let custom_commands = - resolve_custom_commands(npm_resolver.as_ref(), node_resolver)?; + let custom_commands = task_runner::resolve_custom_commands( + npm_resolver.as_ref(), + node_resolver, + )?; run_task(RunTaskOptions { task_name, script, cwd: &cwd, - init_cwd: cli_options.initial_cwd(), env_vars, - argv: cli_options.argv(), custom_commands, - root_node_modules_dir: npm_resolver - .root_node_modules_path() - .map(|p| p.as_path()), + npm_resolver: npm_resolver.as_ref(), + cli_options, }) .await } @@ -125,21 +113,20 @@ pub async fn execute_script( task_name.clone(), format!("post{}", task_name), ]; - let custom_commands = - resolve_custom_commands(npm_resolver.as_ref(), node_resolver)?; + let custom_commands = task_runner::resolve_custom_commands( + npm_resolver.as_ref(), + node_resolver, + )?; for task_name in &task_names { if let Some(script) = scripts.get(task_name) { let exit_code = run_task(RunTaskOptions { task_name, script, cwd: &cwd, - init_cwd: cli_options.initial_cwd(), env_vars: env_vars.clone(), - argv: cli_options.argv(), custom_commands: custom_commands.clone(), - root_node_modules_dir: npm_resolver - .root_node_modules_path() - .map(|p| p.as_path()), + npm_resolver: npm_resolver.as_ref(), + cli_options, }) .await?; if exit_code > 0 { @@ -169,40 +156,41 @@ struct RunTaskOptions<'a> { task_name: &'a str, script: &'a str, cwd: &'a Path, - init_cwd: &'a Path, env_vars: HashMap<String, String>, - argv: &'a [String], custom_commands: HashMap<String, Rc<dyn ShellCommand>>, - root_node_modules_dir: Option<&'a Path>, + npm_resolver: &'a dyn CliNpmResolver, + cli_options: &'a CliOptions, } async fn run_task(opts: RunTaskOptions<'_>) -> Result<i32, AnyError> { - let script = get_script_with_args(opts.script, opts.argv); - output_task(opts.task_name, &script); - let seq_list = deno_task_shell::parser::parse(&script) - .with_context(|| format!("Error parsing script '{}'.", opts.task_name))?; - let env_vars = - prepare_env_vars(opts.env_vars, opts.init_cwd, opts.root_node_modules_dir); - let local = LocalSet::new(); - let future = deno_task_shell::execute( - seq_list, + let RunTaskOptions { + task_name, + script, + cwd, env_vars, - opts.cwd, - opts.custom_commands, + custom_commands, + npm_resolver, + cli_options, + } = opts; + + output_task( + opts.task_name, + &task_runner::get_script_with_args(script, cli_options.argv()), ); - Ok(local.run_until(future).await) -} -fn get_script_with_args(script: &str, argv: &[String]) -> String { - let additional_args = argv - .iter() - // surround all the additional arguments in double quotes - // and sanitize any command substitution - .map(|a| format!("\"{}\"", a.replace('"', "\\\"").replace('$', "\\$"))) - .collect::<Vec<_>>() - .join(" "); - let script = format!("{script} {additional_args}"); - script.trim().to_owned() + task_runner::run_task(task_runner::RunTaskOptions { + task_name, + script, + cwd, + env_vars, + custom_commands, + init_cwd: opts.cli_options.initial_cwd(), + argv: cli_options.argv(), + root_node_modules_dir: npm_resolver + .root_node_modules_path() + .map(|p| p.as_path()), + }) + .await } fn output_task(task_name: &str, script: &str) { @@ -214,56 +202,6 @@ fn output_task(task_name: &str, script: &str) { ); } -fn prepare_env_vars( - mut env_vars: HashMap<String, String>, - initial_cwd: &Path, - node_modules_dir: Option<&Path>, -) -> HashMap<String, String> { - const INIT_CWD_NAME: &str = "INIT_CWD"; - if !env_vars.contains_key(INIT_CWD_NAME) { - // if not set, set an INIT_CWD env var that has the cwd - env_vars.insert( - INIT_CWD_NAME.to_string(), - initial_cwd.to_string_lossy().to_string(), - ); - } - if let Some(node_modules_dir) = node_modules_dir { - prepend_to_path( - &mut env_vars, - node_modules_dir.join(".bin").to_string_lossy().to_string(), - ); - } - env_vars -} - -fn prepend_to_path(env_vars: &mut HashMap<String, String>, value: String) { - match env_vars.get_mut("PATH") { - Some(path) => { - if path.is_empty() { - *path = value; - } else { - *path = - format!("{}{}{}", value, if cfg!(windows) { ";" } else { ":" }, path); - } - } - None => { - env_vars.insert("PATH".to_string(), value); - } - } -} - -fn real_env_vars() -> HashMap<String, String> { - std::env::vars() - .map(|(k, v)| { - if cfg!(windows) { - (k.to_uppercase(), v) - } else { - (k, v) - } - }) - .collect::<HashMap<String, String>>() -} - fn print_available_tasks( writer: &mut dyn std::io::Write, workspace: &Arc<Workspace>, @@ -357,327 +295,3 @@ fn print_available_tasks( Ok(()) } - -struct NpmCommand; - -impl ShellCommand for NpmCommand { - fn execute( - &self, - mut context: ShellCommandContext, - ) -> LocalBoxFuture<'static, ExecuteResult> { - if context.args.first().map(|s| s.as_str()) == Some("run") - && context.args.len() > 2 - // for now, don't run any npm scripts that have a flag because - // we don't handle stuff like `--workspaces` properly - && !context.args.iter().any(|s| s.starts_with('-')) - { - // run with deno task instead - let mut args = Vec::with_capacity(context.args.len()); - args.push("task".to_string()); - args.extend(context.args.iter().skip(1).cloned()); - - let mut state = context.state; - state.apply_env_var(USE_PKG_JSON_HIDDEN_ENV_VAR_NAME, "1"); - return ExecutableCommand::new( - "deno".to_string(), - std::env::current_exe().unwrap(), - ) - .execute(ShellCommandContext { - args, - state, - ..context - }); - } - - // fallback to running the real npm command - let npm_path = match context.state.resolve_command_path("npm") { - Ok(path) => path, - Err(err) => { - let _ = context.stderr.write_line(&format!("{}", err)); - return Box::pin(futures::future::ready( - ExecuteResult::from_exit_code(err.exit_code()), - )); - } - }; - ExecutableCommand::new("npm".to_string(), npm_path).execute(context) - } -} - -struct NpxCommand; - -impl ShellCommand for NpxCommand { - fn execute( - &self, - mut context: ShellCommandContext, - ) -> LocalBoxFuture<'static, ExecuteResult> { - if let Some(first_arg) = context.args.first().cloned() { - if let Some(command) = context.state.resolve_custom_command(&first_arg) { - let context = ShellCommandContext { - args: context.args.iter().skip(1).cloned().collect::<Vec<_>>(), - ..context - }; - command.execute(context) - } else { - // can't find the command, so fallback to running the real npx command - let npx_path = match context.state.resolve_command_path("npx") { - Ok(npx) => npx, - Err(err) => { - let _ = context.stderr.write_line(&format!("{}", err)); - return Box::pin(futures::future::ready( - ExecuteResult::from_exit_code(err.exit_code()), - )); - } - }; - ExecutableCommand::new("npx".to_string(), npx_path).execute(context) - } - } else { - let _ = context.stderr.write_line("npx: missing command"); - Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))) - } - } -} - -#[derive(Clone)] -struct NpmPackageBinCommand { - name: String, - npm_package: PackageNv, -} - -impl ShellCommand for NpmPackageBinCommand { - fn execute( - &self, - context: ShellCommandContext, - ) -> LocalBoxFuture<'static, ExecuteResult> { - let mut args = vec![ - "run".to_string(), - "-A".to_string(), - if self.npm_package.name == self.name { - format!("npm:{}", self.npm_package) - } else { - format!("npm:{}/{}", self.npm_package, self.name) - }, - ]; - args.extend(context.args); - let executable_command = deno_task_shell::ExecutableCommand::new( - "deno".to_string(), - std::env::current_exe().unwrap(), - ); - executable_command.execute(ShellCommandContext { args, ..context }) - } -} - -/// Runs a module in the node_modules folder. -#[derive(Clone)] -struct NodeModulesFileRunCommand { - command_name: String, - path: PathBuf, -} - -impl ShellCommand for NodeModulesFileRunCommand { - fn execute( - &self, - mut context: ShellCommandContext, - ) -> LocalBoxFuture<'static, ExecuteResult> { - let mut args = vec![ - "run".to_string(), - "--ext=js".to_string(), - "-A".to_string(), - self.path.to_string_lossy().to_string(), - ]; - args.extend(context.args); - let executable_command = deno_task_shell::ExecutableCommand::new( - "deno".to_string(), - std::env::current_exe().unwrap(), - ); - // set this environment variable so that the launched process knows the npm command name - context - .state - .apply_env_var("DENO_INTERNAL_NPM_CMD_NAME", &self.command_name); - executable_command.execute(ShellCommandContext { args, ..context }) - } -} - -fn resolve_custom_commands( - npm_resolver: &dyn CliNpmResolver, - node_resolver: &NodeResolver, -) -> Result<HashMap<String, Rc<dyn ShellCommand>>, AnyError> { - let mut commands = match npm_resolver.as_inner() { - InnerCliNpmResolverRef::Byonm(npm_resolver) => { - let node_modules_dir = npm_resolver.root_node_modules_path().unwrap(); - resolve_npm_commands_from_bin_dir(node_modules_dir) - } - InnerCliNpmResolverRef::Managed(npm_resolver) => { - resolve_managed_npm_commands(npm_resolver, node_resolver)? - } - }; - commands.insert("npm".to_string(), Rc::new(NpmCommand)); - Ok(commands) -} - -fn resolve_npm_commands_from_bin_dir( - node_modules_dir: &Path, -) -> HashMap<String, Rc<dyn ShellCommand>> { - let mut result = HashMap::<String, Rc<dyn ShellCommand>>::new(); - let bin_dir = node_modules_dir.join(".bin"); - log::debug!("Resolving commands in '{}'.", bin_dir.display()); - match std::fs::read_dir(&bin_dir) { - Ok(entries) => { - for entry in entries { - let Ok(entry) = entry else { - continue; - }; - if let Some(command) = resolve_bin_dir_entry_command(entry) { - result.insert(command.command_name.clone(), Rc::new(command)); - } - } - } - Err(err) => { - log::debug!("Failed read_dir for '{}': {:#}", bin_dir.display(), err); - } - } - result -} - -fn resolve_bin_dir_entry_command( - entry: std::fs::DirEntry, -) -> Option<NodeModulesFileRunCommand> { - if entry.path().extension().is_some() { - return None; // only look at files without extensions (even on Windows) - } - let file_type = entry.file_type().ok()?; - let path = if file_type.is_file() { - entry.path() - } else if file_type.is_symlink() { - entry.path().canonicalize().ok()? - } else { - return None; - }; - let text = std::fs::read_to_string(&path).ok()?; - let command_name = entry.file_name().to_string_lossy().to_string(); - if let Some(path) = resolve_execution_path_from_npx_shim(path, &text) { - log::debug!( - "Resolved npx command '{}' to '{}'.", - command_name, - path.display() - ); - Some(NodeModulesFileRunCommand { command_name, path }) - } else { - log::debug!("Failed resolving npx command '{}'.", command_name); - None - } -} - -/// This is not ideal, but it works ok because it allows us to bypass -/// the shebang and execute the script directly with Deno. -fn resolve_execution_path_from_npx_shim( - file_path: PathBuf, - text: &str, -) -> Option<PathBuf> { - static SCRIPT_PATH_RE: Lazy<Regex> = - lazy_regex::lazy_regex!(r#""\$basedir\/([^"]+)" "\$@""#); - - if text.starts_with("#!/usr/bin/env node") { - // launch this file itself because it's a JS file - Some(file_path) - } else { - // Search for... - // > "$basedir/../next/dist/bin/next" "$@" - // ...which is what it will look like on Windows - SCRIPT_PATH_RE - .captures(text) - .and_then(|c| c.get(1)) - .map(|relative_path| { - file_path.parent().unwrap().join(relative_path.as_str()) - }) - } -} - -fn resolve_managed_npm_commands( - npm_resolver: &ManagedCliNpmResolver, - node_resolver: &NodeResolver, -) -> Result<HashMap<String, Rc<dyn ShellCommand>>, AnyError> { - let mut result = HashMap::new(); - let snapshot = npm_resolver.snapshot(); - for id in snapshot.top_level_packages() { - let package_folder = npm_resolver.resolve_pkg_folder_from_pkg_id(id)?; - let bin_commands = - node_resolver.resolve_binary_commands(&package_folder)?; - for bin_command in bin_commands { - result.insert( - bin_command.to_string(), - Rc::new(NpmPackageBinCommand { - name: bin_command, - npm_package: id.nv.clone(), - }) as Rc<dyn ShellCommand>, - ); - } - } - if !result.contains_key("npx") { - result.insert("npx".to_string(), Rc::new(NpxCommand)); - } - Ok(result) -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_prepend_to_path() { - let mut env_vars = HashMap::new(); - - prepend_to_path(&mut env_vars, "/example".to_string()); - assert_eq!( - env_vars, - HashMap::from([("PATH".to_string(), "/example".to_string())]) - ); - - prepend_to_path(&mut env_vars, "/example2".to_string()); - let separator = if cfg!(windows) { ";" } else { ":" }; - assert_eq!( - env_vars, - HashMap::from([( - "PATH".to_string(), - format!("/example2{}/example", separator) - )]) - ); - - env_vars.get_mut("PATH").unwrap().clear(); - prepend_to_path(&mut env_vars, "/example".to_string()); - assert_eq!( - env_vars, - HashMap::from([("PATH".to_string(), "/example".to_string())]) - ); - } - - #[test] - fn test_resolve_execution_path_from_npx_shim() { - // example shim on unix - let unix_shim = r#"#!/usr/bin/env node -"use strict"; -console.log('Hi!'); -"#; - let path = PathBuf::from("/node_modules/.bin/example"); - assert_eq!( - resolve_execution_path_from_npx_shim(path.clone(), unix_shim).unwrap(), - path - ); - // example shim on windows - let windows_shim = r#"#!/bin/sh -basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") - -case `uname` in - *CYGWIN*|*MINGW*|*MSYS*) basedir=`cygpath -w "$basedir"`;; -esac - -if [ -x "$basedir/node" ]; then - exec "$basedir/node" "$basedir/../example/bin/example" "$@" -else - exec node "$basedir/../example/bin/example" "$@" -fi"#; - assert_eq!( - resolve_execution_path_from_npx_shim(path.clone(), windows_shim).unwrap(), - path.parent().unwrap().join("../example/bin/example") - ); - } -} |