// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. 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::util::fs::canonicalize_path; 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_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 indexmap::IndexMap; use lazy_regex::Lazy; use regex::Regex; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use std::rc::Rc; use tokio::task::LocalSet; pub async fn execute_script( flags: Flags, task_flags: TaskFlags, ) -> Result { let factory = CliFactory::from_flags(flags)?; let cli_options = factory.cli_options(); let tasks_config = cli_options.resolve_tasks_config()?; let maybe_package_json = cli_options.maybe_package_json(); let package_json_scripts = maybe_package_json .as_ref() .and_then(|p| p.scripts.clone()) .unwrap_or_default(); let task_name = match &task_flags.task { Some(task) => task, None => { print_available_tasks( &mut std::io::stdout(), &tasks_config, &package_json_scripts, )?; return Ok(1); } }; let npm_resolver = factory.npm_resolver().await?; let node_resolver = factory.node_resolver().await?; let env_vars = real_env_vars(); if let Some( deno_config::Task::Definition(script) | deno_config::Task::Commented { definition: script, .. }, ) = tasks_config.get(task_name) { let config_file_url = cli_options.maybe_config_file_specifier().unwrap(); let config_file_path = if config_file_url.scheme() == "file" { config_file_url.to_file_path().unwrap() } else { bail!("Only local configuration files are supported") }; let cwd = match task_flags.cwd { Some(path) => canonicalize_path(&PathBuf::from(path)) .context("failed canonicalizing --cwd")?, None => config_file_path.parent().unwrap().to_owned(), }; let custom_commands = resolve_custom_commands(npm_resolver.as_ref(), node_resolver)?; run_task( task_name, script, &cwd, cli_options.initial_cwd(), env_vars, cli_options.argv(), custom_commands, npm_resolver.root_node_modules_path().map(|p| p.as_path()), ) .await } else if package_json_scripts.contains_key(task_name) { let package_json_deps_provider = factory.package_json_deps_provider(); if let Some(package_deps) = package_json_deps_provider.deps() { for (key, value) in package_deps { if let Err(err) = value { log::info!( "{} Ignoring dependency '{}' in package.json because its version requirement failed to parse: {:#}", colors::yellow("Warning"), key, err, ); } } } // ensure the npm packages are installed if using a node_modules // directory and managed resolver if cli_options.has_node_modules_dir() { if let Some(npm_resolver) = npm_resolver.as_managed() { npm_resolver.ensure_top_level_package_json_install().await?; npm_resolver.resolve_pending().await?; } } let cwd = match task_flags.cwd { Some(path) => canonicalize_path(&PathBuf::from(path))?, None => maybe_package_json .as_ref() .unwrap() .path .parent() .unwrap() .to_owned(), }; // At this point we already checked if the task name exists in package.json. // We can therefore check for "pre" and "post" scripts too, since we're only // dealing with package.json here and not deno.json let task_names = vec![ format!("pre{}", task_name), task_name.clone(), format!("post{}", task_name), ]; let custom_commands = resolve_custom_commands(npm_resolver.as_ref(), node_resolver)?; for task_name in task_names { if let Some(script) = package_json_scripts.get(&task_name) { let exit_code = run_task( &task_name, script, &cwd, cli_options.initial_cwd(), env_vars.clone(), cli_options.argv(), custom_commands.clone(), npm_resolver.root_node_modules_path().map(|p| p.as_path()), ) .await?; if exit_code > 0 { return Ok(exit_code); } } } Ok(0) } else { log::error!("Task not found: {task_name}"); if log::log_enabled!(log::Level::Error) { print_available_tasks( &mut std::io::stderr(), &tasks_config, &package_json_scripts, )?; } Ok(1) } } #[allow(clippy::too_many_arguments)] async fn run_task( task_name: &str, script: &str, cwd: &Path, init_cwd: &Path, env_vars: HashMap, argv: &[String], custom_commands: HashMap>, root_node_modules_dir: Option<&Path>, ) -> Result { let script = get_script_with_args(script, argv); output_task(task_name, &script); let seq_list = deno_task_shell::parser::parse(&script) .with_context(|| format!("Error parsing script '{}'.", task_name))?; let env_vars = prepare_env_vars(env_vars, init_cwd, root_node_modules_dir); let local = LocalSet::new(); let future = deno_task_shell::execute(seq_list, env_vars, cwd, custom_commands); 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::>() .join(" "); let script = format!("{script} {additional_args}"); script.trim().to_owned() } fn output_task(task_name: &str, script: &str) { log::info!( "{} {} {}", colors::green("Task"), colors::cyan(task_name), script, ); } fn prepare_env_vars( mut env_vars: HashMap, initial_cwd: &Path, node_modules_dir: Option<&Path>, ) -> HashMap { 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, 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 { std::env::vars() .map(|(k, v)| { if cfg!(windows) { (k.to_uppercase(), v) } else { (k, v) } }) .collect::>() } fn print_available_tasks( writer: &mut dyn std::io::Write, tasks_config: &IndexMap, package_json_scripts: &IndexMap, ) -> Result<(), std::io::Error> { writeln!(writer, "{}", colors::green("Available tasks:"))?; if tasks_config.is_empty() && package_json_scripts.is_empty() { writeln!( writer, " {}", colors::red("No tasks found in configuration file") )?; } else { for (is_deno, (key, task)) in tasks_config .iter() .map(|(k, t)| (true, (k, t.clone()))) .chain( package_json_scripts .iter() .filter(|(key, _)| !tasks_config.contains_key(*key)) .map(|(k, v)| (false, (k, deno_config::Task::Definition(v.clone())))), ) { writeln!( writer, "- {}{}", colors::cyan(key), if is_deno { "".to_string() } else { format!(" {}", colors::italic_gray("(package.json)")) } )?; let definition = match &task { deno_config::Task::Definition(definition) => definition, deno_config::Task::Commented { definition, .. } => definition, }; if let deno_config::Task::Commented { comments, .. } = &task { let slash_slash = colors::italic_gray("//"); for comment in comments { writeln!( writer, " {slash_slash} {}", colors::italic_gray(comment) )?; } } writeln!(writer, " {definition}")?; } } Ok(()) } 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_command(&first_arg) { let context = ShellCommandContext { args: context.args.iter().skip(1).cloned().collect::>(), ..context }; command.execute(context) } else { // can't find the command, so fallback to running the real npx command let npx_path = match context.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>, AnyError> { match npm_resolver.as_inner() { InnerCliNpmResolverRef::Byonm(npm_resolver) => { let node_modules_dir = npm_resolver.root_node_modules_path().unwrap(); Ok(resolve_npm_commands_from_bin_dir(node_modules_dir)) } InnerCliNpmResolverRef::Managed(npm_resolver) => { resolve_managed_npm_commands(npm_resolver, node_resolver) } } } fn resolve_npm_commands_from_bin_dir( node_modules_dir: &Path, ) -> HashMap> { let mut result = HashMap::>::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 { 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 { static SCRIPT_PATH_RE: Lazy = 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>, 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, ); } } 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") ); } }