// Copyright 2018-2025 the Deno authors. MIT license. use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsString; use std::num::NonZeroUsize; use std::path::Path; use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use console_static_text::ansi::strip_ansi_codes; use deno_config::workspace::FolderConfigs; use deno_config::workspace::TaskDefinition; use deno_config::workspace::TaskOrScript; use deno_config::workspace::WorkspaceDirectory; use deno_config::workspace::WorkspaceMemberTasksConfig; use deno_config::workspace::WorkspaceTasksConfig; use deno_core::anyhow::Context; use deno_core::anyhow::anyhow; use deno_core::anyhow::bail; use deno_core::error::AnyError; use deno_core::futures::FutureExt; use deno_core::futures::StreamExt; use deno_core::futures::future::LocalBoxFuture; use deno_core::futures::stream::futures_unordered; use deno_core::url::Url; use deno_npm_installer::PackageCaching; use deno_path_util::normalize_path; use deno_task_shell::KillSignal; use deno_task_shell::ShellCommand; use indexmap::IndexMap; use indexmap::IndexSet; use regex::Regex; use crate::args::CliLockfile; use crate::args::CliOptions; use crate::args::Flags; use crate::args::TaskFlags; use crate::colors; use crate::factory::CliFactory; use crate::node::CliNodeResolver; use crate::npm::CliNpmInstaller; use crate::npm::CliNpmResolver; use crate::task_runner; use crate::task_runner::run_future_forwarding_signals; use crate::util::fs::canonicalize_path; use crate::util::progress_bar::ProgressBar; #[derive(Debug)] struct PackageTaskInfo { matched_tasks: Vec, tasks_config: WorkspaceTasksConfig, } pub async fn execute_script( flags: Arc, task_flags: TaskFlags, ) -> Result { let factory = CliFactory::from_flags(flags.clone()); let cli_options = factory.cli_options()?; let start_dir = &cli_options.start_dir; if !start_dir.has_deno_or_pkg_json() && !task_flags.eval { bail!( "deno task couldn't find deno.json(c) or package.json. See https://docs.deno.com/go/config" ) } 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 #[allow(clippy::undocumented_unsafe_blocks)] unsafe { std::env::remove_var( crate::task_runner::USE_PKG_JSON_HIDDEN_ENV_VAR_NAME, ) }; v == "1" }) .unwrap_or(false); // TODO(bartlomieju): this whole huge if statement should be a separate function, preferably with unit tests let (packages_task_configs, name) = if let Some(filter) = &task_flags.filter { // Filter based on package name let package_regex = package_filter_to_regex(filter)?; let Some(task_name) = &task_flags.task else { print_available_tasks_workspace( cli_options, &package_regex, filter, force_use_pkg_json, task_flags.recursive, )?; return Ok(0); }; let task_regex = arg_to_task_name_filter(task_name)?; let mut packages_task_info: Vec = vec![]; let workspace = cli_options.workspace(); for (folder_url, folder) in workspace.config_folders_sorted_by_dependencies() { if !task_flags.recursive && !matches_package(folder, force_use_pkg_json, &package_regex) { continue; } let member_dir = workspace.resolve_member_dir(folder_url); let mut tasks_config = member_dir.to_tasks_config()?; if force_use_pkg_json { tasks_config = tasks_config.with_only_pkg_json(); } let matched_tasks = match_tasks(&tasks_config, &task_regex); if matched_tasks.is_empty() { continue; } packages_task_info.push(PackageTaskInfo { matched_tasks, tasks_config, }); } // Logging every task definition would be too spammy. Pnpm only // logs a simple message too. if packages_task_info .iter() .all(|config| config.matched_tasks.is_empty()) { log::warn!( "{}", colors::red(format!( "No matching task or script '{}' found in selected packages.", task_name )) ); return Ok(0); } (packages_task_info, task_name) } else { let mut tasks_config = start_dir.to_tasks_config()?; if force_use_pkg_json { tasks_config = tasks_config.with_only_pkg_json() } let Some(task_name) = &task_flags.task else { print_available_tasks( &mut std::io::stdout(), &cli_options.start_dir, &tasks_config, None, )?; return Ok(0); }; let task_regex = arg_to_task_name_filter(task_name)?; let matched_tasks = match_tasks(&tasks_config, &task_regex); ( vec![PackageTaskInfo { tasks_config, matched_tasks, }], task_name, ) }; let maybe_lockfile = factory.maybe_lockfile().await?.cloned(); let npm_installer = factory.npm_installer_if_managed().await?; let npm_resolver = factory.npm_resolver().await?; let node_resolver = factory.node_resolver().await?; let progress_bar = factory.text_only_progress_bar(); let mut env_vars = task_runner::real_env_vars(); if flags.connected { env_vars.insert("DENO_CONNECTED".into(), "1".into()); } let no_of_concurrent_tasks = if let Ok(value) = std::env::var("DENO_JOBS") { value.parse::().ok() } else { std::thread::available_parallelism().ok() } .unwrap_or_else(|| NonZeroUsize::new(2).unwrap()); let task_runner = TaskRunner { task_flags: &task_flags, npm_installer: npm_installer.map(|n| n.as_ref()), npm_resolver, node_resolver: node_resolver.as_ref(), progress_bar, env_vars, cli_options, maybe_lockfile, concurrency: no_of_concurrent_tasks.into(), }; let kill_signal = KillSignal::default(); run_future_forwarding_signals(kill_signal.clone(), async { if task_flags.eval { return task_runner .run_deno_task( &Url::from_directory_path(cli_options.initial_cwd()).unwrap(), None, "", &TaskDefinition { command: Some(task_flags.task.as_ref().unwrap().to_string()), dependencies: vec![], description: None, }, kill_signal, cli_options.argv(), ) .await; } for task_config in &packages_task_configs { let exit_code = task_runner .run_tasks(task_config, name, &kill_signal, cli_options.argv()) .await?; if exit_code > 0 { return Ok(exit_code); } } Ok(0) }) .await } struct RunSingleOptions<'a> { task_name: &'a str, package_name: Option<&'a str>, script: &'a str, cwd: PathBuf, custom_commands: HashMap>, kill_signal: KillSignal, argv: &'a [String], } struct TaskRunner<'a> { task_flags: &'a TaskFlags, npm_installer: Option<&'a CliNpmInstaller>, npm_resolver: &'a CliNpmResolver, node_resolver: &'a CliNodeResolver, progress_bar: &'a ProgressBar, env_vars: HashMap, cli_options: &'a CliOptions, maybe_lockfile: Option>, concurrency: usize, } impl<'a> TaskRunner<'a> { pub async fn run_tasks( &self, pkg_tasks_config: &PackageTaskInfo, task_name: &str, kill_signal: &KillSignal, argv: &[String], ) -> Result { match sort_tasks_topo(pkg_tasks_config, task_name) { Ok(sorted) => self.run_tasks_in_parallel(sorted, kill_signal, argv).await, Err(err) => match err { TaskError::NotFound(name) => { if self.task_flags.is_run { return Err(anyhow!("Task not found: {}", name)); } log::error!("Task not found: {}", name); if log::log_enabled!(log::Level::Error) { self.print_available_tasks(&pkg_tasks_config.tasks_config)?; } Ok(1) } TaskError::TaskDepCycle { path } => { log::error!("Task cycle detected: {}", path.join(" -> ")); Ok(1) } }, } } pub fn print_available_tasks( &self, tasks_config: &WorkspaceTasksConfig, ) -> Result<(), std::io::Error> { print_available_tasks( &mut std::io::stderr(), &self.cli_options.start_dir, tasks_config, None, ) } async fn run_tasks_in_parallel( &self, tasks: Vec>, kill_signal: &KillSignal, args: &[String], ) -> Result { struct PendingTasksContext<'a> { completed: HashSet, running: HashSet, tasks: &'a [ResolvedTask<'a>], } impl<'a> PendingTasksContext<'a> { fn has_remaining_tasks(&self) -> bool { self.completed.len() < self.tasks.len() } fn mark_complete(&mut self, task: &ResolvedTask) { self.running.remove(&task.id); self.completed.insert(task.id); } fn get_next_task<'b>( &mut self, runner: &'b TaskRunner<'b>, kill_signal: &KillSignal, argv: &'a [String], ) -> Option< LocalBoxFuture<'b, Result<(i32, &'a ResolvedTask<'a>), AnyError>>, > where 'a: 'b, { let mut tasks_iter = self.tasks.iter().peekable(); while let Some(task) = tasks_iter.next() { let args = if tasks_iter.peek().is_none() { argv } else { &[] }; if self.completed.contains(&task.id) || self.running.contains(&task.id) { continue; } let should_run = task .dependencies .iter() .all(|dep_id| self.completed.contains(dep_id)); if !should_run { continue; } self.running.insert(task.id); let kill_signal = kill_signal.clone(); return Some( async move { match task.task_or_script { TaskOrScript::Task { task: def, .. } => { runner .run_deno_task( task.task_or_script.folder_url(), task.task_or_script.package_name(), task.name, def, kill_signal, args, ) .await } TaskOrScript::Script { details, .. } => { runner .run_npm_script( task.task_or_script.folder_url(), task.task_or_script.package_name(), task.name, &details.tasks, kill_signal, args, ) .await } } .map(|exit_code| (exit_code, task)) } .boxed_local(), ); } None } } let mut context = PendingTasksContext { completed: HashSet::with_capacity(tasks.len()), running: HashSet::with_capacity(self.concurrency), tasks: &tasks, }; let mut queue = futures_unordered::FuturesUnordered::new(); while context.has_remaining_tasks() { while queue.len() < self.concurrency { match context.get_next_task(self, kill_signal, args) { Some(task) => { queue.push(task); } _ => { break; } } } // If queue is empty at this point, then there are no more tasks in the queue. let Some(result) = queue.next().await else { debug_assert_eq!(context.tasks.len(), 0); break; }; let (exit_code, name) = result?; if exit_code > 0 { return Ok(exit_code); } context.mark_complete(name); } Ok(0) } pub async fn run_deno_task( &self, dir_url: &Url, package_name: Option<&str>, task_name: &str, definition: &TaskDefinition, kill_signal: KillSignal, argv: &'a [String], ) -> Result { let Some(command) = &definition.command else { self.output_task( task_name, package_name, &colors::gray("(no command)").to_string(), ); return Ok(0); }; self.maybe_npm_install().await?; let cwd = match &self.task_flags.cwd { Some(path) => canonicalize_path(Path::new(path)) .context("failed canonicalizing --cwd")?, None => { normalize_path(Cow::Owned(dir_url.to_file_path().unwrap())).into_owned() } }; let custom_commands = task_runner::resolve_custom_commands( self.npm_resolver, self.node_resolver, )?; self .run_single(RunSingleOptions { task_name, package_name, script: command, cwd, custom_commands, kill_signal, argv, }) .await } pub async fn run_npm_script( &self, dir_url: &Url, package_name: Option<&str>, task_name: &str, scripts: &IndexMap, kill_signal: KillSignal, argv: &[String], ) -> Result { // ensure the npm packages are installed if using a managed resolver self.maybe_npm_install().await?; let cwd = match &self.task_flags.cwd { Some(path) => Cow::Owned(canonicalize_path(Path::new(path))?), None => normalize_path(Cow::Owned(dir_url.to_file_path().unwrap())), }; // 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.to_string(), format!("post{}", task_name), ]; let custom_commands = task_runner::resolve_custom_commands( self.npm_resolver, self.node_resolver, )?; for task_name in &task_names { if let Some(script) = scripts.get(task_name) { let exit_code = self .run_single(RunSingleOptions { task_name, package_name, script, cwd: cwd.to_path_buf(), custom_commands: custom_commands.clone(), kill_signal: kill_signal.clone(), argv, }) .await?; if exit_code > 0 { return Ok(exit_code); } } } Ok(0) } async fn run_single( &self, opts: RunSingleOptions<'_>, ) -> Result { let RunSingleOptions { task_name, package_name, script, cwd, custom_commands, kill_signal, argv, } = opts; self.output_task( task_name, package_name, &task_runner::get_script_with_args(script, argv), ); Ok( task_runner::run_task(task_runner::RunTaskOptions { task_name, script, cwd, env_vars: self.env_vars.clone(), custom_commands, init_cwd: self.cli_options.initial_cwd(), argv, root_node_modules_dir: self.npm_resolver.root_node_modules_path(), stdio: None, kill_signal, }) .await? .exit_code, ) } async fn maybe_npm_install(&self) -> Result<(), AnyError> { if let Some(npm_installer) = self.npm_installer { self.progress_bar.deferred_keep_initialize_alive(); npm_installer .ensure_top_level_package_json_install() .await?; npm_installer.cache_packages(PackageCaching::All).await?; if let Some(lockfile) = &self.maybe_lockfile { lockfile.write_if_changed()?; } } Ok(()) } fn output_task( &self, task_name: &str, package_name: Option<&str>, script: &str, ) { log::info!( "{} {}{} {}", colors::green("Task"), colors::cyan(task_name), package_name .filter( |_| self.task_flags.recursive || self.task_flags.filter.is_some() ) .map(|p| format!(" ({})", colors::gray(p))) .unwrap_or_default(), script, ); } } #[derive(Debug)] enum TaskError { NotFound(String), TaskDepCycle { path: Vec }, } struct ResolvedTask<'a> { id: usize, name: &'a str, task_or_script: TaskOrScript<'a>, dependencies: Vec, } fn sort_tasks_topo<'a>( pkg_task_config: &'a PackageTaskInfo, task_name: &str, ) -> Result>, TaskError> { trait TasksConfig { fn task(&self, name: &str) -> Option<(TaskOrScript<'_>, &dyn TasksConfig)>; } impl TasksConfig for WorkspaceTasksConfig { fn task(&self, name: &str) -> Option<(TaskOrScript<'_>, &dyn TasksConfig)> { if let Some(member) = &self.member && let Some(task_or_script) = member.task(name) { return Some((task_or_script, self as &dyn TasksConfig)); } if let Some(root) = &self.root && let Some(task_or_script) = root.task(name) { // switch to only using the root tasks for the dependencies return Some((task_or_script, root as &dyn TasksConfig)); } None } } impl TasksConfig for WorkspaceMemberTasksConfig { fn task(&self, name: &str) -> Option<(TaskOrScript<'_>, &dyn TasksConfig)> { self .task(name) .map(|task_or_script| (task_or_script, self as &dyn TasksConfig)) } } fn sort_visit<'a>( name: &'a str, sorted: &mut Vec>, mut path: Vec<(&'a Url, &'a str)>, tasks_config: &'a dyn TasksConfig, ) -> Result { let Some((task_or_script, tasks_config)) = tasks_config.task(name) else { return Err(TaskError::NotFound(name.to_string())); }; let folder_url = task_or_script.folder_url(); if let Some(existing_task) = sorted.iter().find(|task| { task.name == name && task.task_or_script.folder_url() == folder_url }) { // already exists return Ok(existing_task.id); } if path.contains(&(folder_url, name)) { path.push((folder_url, name)); return Err(TaskError::TaskDepCycle { path: path.iter().map(|(_, s)| s.to_string()).collect(), }); } let mut dependencies: Vec = Vec::new(); if let TaskOrScript::Task { task, .. } = task_or_script { dependencies.reserve(task.dependencies.len()); for dep in &task.dependencies { let mut path = path.clone(); path.push((folder_url, name)); dependencies.push(sort_visit(dep, sorted, path, tasks_config)?); } } let id = sorted.len(); sorted.push(ResolvedTask { id, name, task_or_script, dependencies, }); Ok(id) } let mut sorted: Vec> = vec![]; for name in &pkg_task_config.matched_tasks { sort_visit(name, &mut sorted, Vec::new(), &pkg_task_config.tasks_config)?; } if sorted.is_empty() { return Err(TaskError::NotFound(task_name.to_string())); } Ok(sorted) } fn matches_package( config: &FolderConfigs, force_use_pkg_json: bool, regex: &Regex, ) -> bool { if !force_use_pkg_json && let Some(deno_json) = &config.deno_json && let Some(name) = &deno_json.json.name && regex.is_match(name) { return true; } if let Some(package_json) = &config.pkg_json && let Some(name) = &package_json.name && regex.is_match(name) { return true; } false } fn print_available_tasks_workspace( cli_options: &Arc, package_regex: &Regex, filter: &str, force_use_pkg_json: bool, recursive: bool, ) -> Result<(), AnyError> { let workspace = cli_options.workspace(); let mut matched = false; for (folder_url, folder) in workspace.config_folders() { if !recursive && !matches_package(folder, force_use_pkg_json, package_regex) { continue; } matched = true; let member_dir = workspace.resolve_member_dir(folder_url); let mut tasks_config = member_dir.to_tasks_config()?; let mut pkg_name = folder .deno_json .as_ref() .and_then(|deno| deno.json.name.clone()) .or(folder.pkg_json.as_ref().and_then(|pkg| pkg.name.clone())); if force_use_pkg_json { tasks_config = tasks_config.with_only_pkg_json(); pkg_name = folder.pkg_json.as_ref().and_then(|pkg| pkg.name.clone()); } print_available_tasks( &mut std::io::stdout(), &cli_options.start_dir, &tasks_config, pkg_name, )?; } if !matched { log::warn!( "{}", colors::red(format!( "No package name matched the filter '{}' in available 'deno.json' or 'package.json' files.", filter )) ); } Ok(()) } fn print_available_tasks( writer: &mut dyn std::io::Write, workspace_dir: &Arc, tasks_config: &WorkspaceTasksConfig, pkg_name: Option, ) -> Result<(), std::io::Error> { let heading = if let Some(s) = pkg_name { format!("Available tasks ({}):", colors::cyan(s)) } else { "Available tasks:".to_string() }; writeln!(writer, "{}", colors::green(heading))?; let is_cwd_root_dir = tasks_config.root.is_none(); if tasks_config.is_empty() { writeln!( writer, " {}", colors::red("No tasks found in configuration file") )?; return Ok(()); } struct AvailableTaskDescription { is_root: bool, is_deno: bool, name: String, task: TaskDefinition, } let mut seen_task_names = HashSet::with_capacity(tasks_config.tasks_count()); let mut task_descriptions = Vec::with_capacity(tasks_config.tasks_count()); for maybe_config in [&tasks_config.member, &tasks_config.root] { let Some(config) = maybe_config else { continue; }; if let Some(config) = config.deno_json.as_ref() { let is_root = !is_cwd_root_dir && config.folder_url == *workspace_dir.workspace.root_dir_url().as_ref(); for (name, definition) in &config.tasks { if !seen_task_names.insert(name) { continue; // already seen } task_descriptions.push(AvailableTaskDescription { is_root, is_deno: true, name: name.to_string(), task: definition.clone(), }); } } if let Some(config) = config.package_json.as_ref() { let is_root = !is_cwd_root_dir && config.folder_url == *workspace_dir.workspace.root_dir_url().as_ref(); for (name, script) in &config.tasks { if !seen_task_names.insert(name) { continue; // already seen } task_descriptions.push(AvailableTaskDescription { is_root, is_deno: false, name: name.to_string(), task: deno_config::deno_json::TaskDefinition { command: Some(script.to_string()), dependencies: vec![], description: None, }, }); } } } for desc in task_descriptions { writeln!( writer, "- {}{}", colors::cyan(desc.name), if desc.is_root { if desc.is_deno { format!(" {}", colors::italic_gray("(workspace)")) } else { format!(" {}", colors::italic_gray("(workspace package.json)")) } } else if desc.is_deno { "".to_string() } else { format!(" {}", colors::italic_gray("(package.json)")) } )?; if let Some(description) = &desc.task.description { let slash_slash = colors::italic_gray("//"); for line in description.lines() { writeln!( writer, " {slash_slash} {}", colors::italic_gray(strip_ansi_codes_and_escape_control_chars(line)) )?; } } if let Some(command) = &desc.task.command { writeln!( writer, " {}", strip_ansi_codes_and_escape_control_chars(command) )?; }; if !desc.task.dependencies.is_empty() { let dependencies = desc .task .dependencies .into_iter() .map(|d| strip_ansi_codes_and_escape_control_chars(&d)) .collect::>() .join(", "); writeln!( writer, " {} {}", colors::gray("depends on:"), colors::cyan(dependencies) )?; } } Ok(()) } fn strip_ansi_codes_and_escape_control_chars(s: &str) -> String { strip_ansi_codes(s) .chars() .map(|c| match c { '\n' => "\\n".to_string(), '\r' => "\\r".to_string(), '\t' => "\\t".to_string(), c if c.is_control() => format!("\\x{:02x}", c as u8), c => c.to_string(), }) .collect() } fn visit_task_and_dependencies( tasks_config: &WorkspaceTasksConfig, visited: &mut HashSet, name: &str, ) { if visited.contains(name) { return; } visited.insert(name.to_string()); if let Some(TaskOrScript::Task { task, .. }) = &tasks_config.task(name) { for dep in &task.dependencies { visit_task_and_dependencies(tasks_config, visited, dep); } } } // Any of the matched tasks could be a child task of another matched // one. Therefore we need to filter these out to ensure that every // task is only run once. fn match_tasks( tasks_config: &WorkspaceTasksConfig, task_name_filter: &TaskNameFilter, ) -> Vec { let mut matched: IndexSet = IndexSet::new(); let mut visited: HashSet = HashSet::new(); // Match tasks in deno.json for name in tasks_config.task_names() { if task_name_filter.matches(name) && !visited.contains(name) { matched.insert(name.to_string()); visit_task_and_dependencies(tasks_config, &mut visited, name); } } matched.iter().map(|s| s.to_string()).collect::>() } fn package_filter_to_regex(input: &str) -> Result { let mut regex_str = regex::escape(input); regex_str = regex_str.replace("\\*", ".*"); Regex::new(®ex_str) } fn arg_to_task_name_filter( input: &str, ) -> Result, AnyError> { if !input.contains("*") { return Ok(TaskNameFilter::Exact(input)); } let mut regex_str = regex::escape(input); regex_str = regex_str.replace("\\*", ".*"); regex_str = format!("^{}", regex_str); let re = Regex::new(®ex_str)?; Ok(TaskNameFilter::Regex(re)) } #[derive(Debug)] enum TaskNameFilter<'s> { Exact(&'s str), Regex(regex::Regex), } impl TaskNameFilter<'_> { fn matches(&self, name: &str) -> bool { match self { Self::Exact(n) => *n == name, Self::Regex(re) => re.is_match(name), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_arg_to_task_name_filter() { assert!(matches!( arg_to_task_name_filter("test").unwrap(), TaskNameFilter::Exact("test") )); assert!(matches!( arg_to_task_name_filter("test-").unwrap(), TaskNameFilter::Exact("test-") )); assert!(matches!( arg_to_task_name_filter("test*").unwrap(), TaskNameFilter::Regex(_) )); let filter = arg_to_task_name_filter("test:*").unwrap(); assert!(filter.matches("test:deno")); assert!(filter.matches("test:dprint")); assert!(!filter.matches("update:latest:deno")); } }