diff options
Diffstat (limited to 'cli/task_runner.rs')
-rw-r--r-- | cli/task_runner.rs | 506 |
1 files changed, 506 insertions, 0 deletions
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") + ); + } +} |