Skip to content

Commit 1115186

Browse files
authored
fix(task): walk ancestor node_modules/.bin in BYONM mode (#34364)
In pnpm-style workspace layouts, hoisted dependencies live only in the workspace root's `node_modules/.bin` — the member packages have no `node_modules` of their own. Running `deno task` from a member failed with `command not found` for those bins because BYONM resolution consulted only the single closest `node_modules/.bin`. Match Node, npm, and pnpm by walking up from the task cwd through every `<ancestor>/node_modules/.bin`, with the closest entry winning for both shell command resolution and the `PATH` env var passed to spawned processes. Managed npm is unaffected since it always uses a single flat `node_modules` layout. Closes #25845
1 parent 57853cb commit 1115186

8 files changed

Lines changed: 101 additions & 14 deletions

File tree

cli/npm.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,9 @@ impl DenoTaskLifeCycleScriptsExecutor {
563563
custom_commands: custom_commands.clone(),
564564
init_cwd: options.init_cwd,
565565
argv: &[],
566-
root_node_modules_dir: Some(options.root_node_modules_dir_path),
566+
node_modules_bin_dirs: &[options
567+
.root_node_modules_dir_path
568+
.join(".bin")],
567569
stdio: Some(crate::task_runner::TaskIo {
568570
stderr: TaskStdio::piped(),
569571
stdout: TaskStdio::piped(),

cli/task_runner.rs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ pub struct RunTaskOptions<'a> {
165165
pub env_vars: HashMap<OsString, OsString>,
166166
pub argv: &'a [String],
167167
pub custom_commands: HashMap<String, Rc<dyn ShellCommand>>,
168-
pub root_node_modules_dir: Option<&'a Path>,
168+
/// Directories to prepend to `PATH` for the duration of the task,
169+
/// ordered closest-first (closest entry ends up first in `PATH`).
170+
pub node_modules_bin_dirs: &'a [PathBuf],
169171
pub stdio: Option<TaskIo>,
170172
pub kill_signal: KillSignal,
171173
}
@@ -185,7 +187,7 @@ pub async fn run_task(
185187
let seq_list = deno_task_shell::parser::parse(&script)
186188
.with_context(|| format!("Error parsing script '{}'.", opts.task_name))?;
187189
let env_vars =
188-
prepare_env_vars(opts.env_vars, opts.init_cwd, opts.root_node_modules_dir);
190+
prepare_env_vars(opts.env_vars, opts.init_cwd, opts.node_modules_bin_dirs);
189191
if !opts.custom_commands.contains_key("deno") {
190192
opts
191193
.custom_commands
@@ -244,7 +246,7 @@ pub async fn run_task(
244246
fn prepare_env_vars(
245247
mut env_vars: HashMap<OsString, OsString>,
246248
initial_cwd: &Path,
247-
node_modules_dir: Option<&Path>,
249+
node_modules_bin_dirs: &[PathBuf],
248250
) -> HashMap<OsString, OsString> {
249251
const INIT_CWD_NAME: &str = "INIT_CWD";
250252
if !env_vars.contains_key(OsStr::new(INIT_CWD_NAME)) {
@@ -262,11 +264,9 @@ fn prepare_env_vars(
262264
crate::npm::get_npm_config_user_agent().into(),
263265
);
264266
}
265-
if let Some(node_modules_dir) = node_modules_dir {
266-
prepend_to_path(
267-
&mut env_vars,
268-
node_modules_dir.join(".bin").into_os_string(),
269-
);
267+
// Prepend in reverse so the closest dir ends up first in `PATH`.
268+
for bin_dir in node_modules_bin_dirs.iter().rev() {
269+
prepend_to_path(&mut env_vars, bin_dir.as_os_str().to_os_string());
270270
}
271271
env_vars
272272
}
@@ -533,12 +533,20 @@ impl ShellCommand for NodeModulesFileRunCommand {
533533
pub fn resolve_custom_commands(
534534
node_resolver: &CliNodeResolver,
535535
npm_resolver: &CliNpmResolver,
536+
bin_dirs: &[PathBuf],
536537
) -> Result<HashMap<String, Rc<dyn ShellCommand>>, AnyError> {
537538
let mut commands = match npm_resolver {
538-
CliNpmResolver::Byonm(npm_resolver) => {
539-
let node_modules_dir = npm_resolver.root_node_modules_path().unwrap();
540-
let bin_dir = node_modules_dir.join(".bin");
541-
resolve_npm_commands_from_bin_dir(&bin_dir, node_resolver)
539+
CliNpmResolver::Byonm(_) => {
540+
// Walk the bin dirs in order (closest first) and merge; closest wins.
541+
let mut commands: HashMap<String, Rc<dyn ShellCommand>> = HashMap::new();
542+
for bin_dir in bin_dirs {
543+
for (name, cmd) in
544+
resolve_npm_commands_from_bin_dir(bin_dir, node_resolver)
545+
{
546+
commands.entry(name).or_insert(cmd);
547+
}
548+
}
549+
commands
542550
}
543551
CliNpmResolver::Managed(npm_resolver) => {
544552
resolve_managed_npm_commands(node_resolver, npm_resolver)?
@@ -548,6 +556,28 @@ pub fn resolve_custom_commands(
548556
Ok(commands)
549557
}
550558

559+
/// Builds the list of `node_modules/.bin` directories to consult for a task.
560+
///
561+
/// For BYONM this walks up the filesystem from `cwd` collecting every
562+
/// `<ancestor>/node_modules/.bin` directory (matching how Node, npm, and pnpm
563+
/// resolve bin commands). For the managed npm resolver there is only ever a
564+
/// single `node_modules/.bin`.
565+
pub fn resolve_task_node_modules_bin_dirs(
566+
npm_resolver: &CliNpmResolver,
567+
cwd: &Path,
568+
) -> Vec<PathBuf> {
569+
match npm_resolver {
570+
CliNpmResolver::Byonm(_) => cwd
571+
.ancestors()
572+
.map(|dir| dir.join("node_modules").join(".bin"))
573+
.collect(),
574+
CliNpmResolver::Managed(npm_resolver) => npm_resolver
575+
.root_node_modules_path()
576+
.map(|p| vec![p.join(".bin")])
577+
.unwrap_or_default(),
578+
}
579+
}
580+
551581
pub fn resolve_npm_commands_from_bin_dir(
552582
bin_dir: &Path,
553583
node_resolver: &CliNodeResolver,

cli/tools/task.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ struct RunSingleOptions<'a> {
318318
script: &'a str,
319319
cwd: PathBuf,
320320
custom_commands: HashMap<String, Rc<dyn ShellCommand>>,
321+
node_modules_bin_dirs: Vec<PathBuf>,
321322
kill_signal: KillSignal,
322323
argv: &'a [String],
323324
parallel_info: Option<ParallelInfo>,
@@ -566,9 +567,12 @@ impl<'a> TaskRunner<'a> {
566567
}
567568
};
568569

570+
let node_modules_bin_dirs =
571+
task_runner::resolve_task_node_modules_bin_dirs(self.npm_resolver, &cwd);
569572
let custom_commands = task_runner::resolve_custom_commands(
570573
self.node_resolver,
571574
self.npm_resolver,
575+
&node_modules_bin_dirs,
572576
)?;
573577

574578
self
@@ -578,6 +582,7 @@ impl<'a> TaskRunner<'a> {
578582
script: command,
579583
cwd,
580584
custom_commands,
585+
node_modules_bin_dirs,
581586
kill_signal,
582587
argv,
583588
parallel_info,
@@ -615,9 +620,12 @@ impl<'a> TaskRunner<'a> {
615620
task_name.to_string(),
616621
format!("post{}", task_name),
617622
];
623+
let node_modules_bin_dirs =
624+
task_runner::resolve_task_node_modules_bin_dirs(self.npm_resolver, &cwd);
618625
let custom_commands = task_runner::resolve_custom_commands(
619626
self.node_resolver,
620627
self.npm_resolver,
628+
&node_modules_bin_dirs,
621629
)?;
622630

623631
for task_name in &task_names {
@@ -629,6 +637,7 @@ impl<'a> TaskRunner<'a> {
629637
script,
630638
cwd: cwd.to_path_buf(),
631639
custom_commands: custom_commands.clone(),
640+
node_modules_bin_dirs: node_modules_bin_dirs.clone(),
632641
kill_signal: kill_signal.clone(),
633642
argv,
634643
parallel_info,
@@ -653,6 +662,7 @@ impl<'a> TaskRunner<'a> {
653662
script,
654663
cwd,
655664
custom_commands,
665+
node_modules_bin_dirs,
656666
kill_signal,
657667
argv,
658668
parallel_info,
@@ -686,7 +696,7 @@ impl<'a> TaskRunner<'a> {
686696
custom_commands,
687697
init_cwd: self.cli_options.initial_cwd(),
688698
argv,
689-
root_node_modules_dir: self.npm_resolver.root_node_modules_path(),
699+
node_modules_bin_dirs: &node_modules_bin_dirs,
690700
stdio,
691701
kill_signal,
692702
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"tempDir": true,
3+
"tests": {
4+
// pnpm-style workspace layout: root has node_modules/.bin/cowsay, but
5+
// packages/child has no node_modules of its own. Running a task from
6+
// the child must still resolve the bin by walking ancestors.
7+
"child_finds_root_bin": {
8+
"steps": [{
9+
"commandName": "npm",
10+
"args": "install",
11+
"output": "[WILDCARD]"
12+
}, {
13+
"args": "task -f child say",
14+
"output": "child_say.out"
15+
}]
16+
}
17+
}
18+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Task say (child) cowsay hi
2+
____
3+
< hi >
4+
----
5+
\ ^__^
6+
\ (oo)\_______
7+
(__)\ )\/\
8+
||----w |
9+
|| ||
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"workspace": ["./packages/child"],
3+
"unstable": ["byonm"]
4+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "root",
3+
"private": true,
4+
"dependencies": {
5+
"cowsay": "*"
6+
}
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "child",
3+
"version": "1.0.0",
4+
"scripts": {
5+
"say": "cowsay hi"
6+
}
7+
}

0 commit comments

Comments
 (0)