Skip to content

Commit 82d1e73

Browse files
committed
Enhance node resolution by adding real node path priority handling and updating environment variables in command execution. Introduce tests for package manager shebang preservation and real node path management.
1 parent 7010021 commit 82d1e73

4 files changed

Lines changed: 217 additions & 18 deletions

File tree

src/app/version.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::{
55
detect::detect,
66
resolve::{ResolveContext, version_command_for_pm},
77
},
8-
platform::node::resolve_real_node_path,
8+
platform::node::{REAL_NODE_ENV, path_with_real_node_priority, resolve_real_node_path},
99
};
1010

1111
pub fn print_versions(ctx: &ResolveContext) {
@@ -45,11 +45,17 @@ pub fn print_versions(ctx: &ResolveContext) {
4545
}
4646

4747
fn run_version(program: String, args: Vec<String>, ctx: &ResolveContext) -> Option<String> {
48-
let output = Command::new(program)
49-
.args(args)
50-
.current_dir(&ctx.cwd)
51-
.output()
52-
.ok()?;
48+
let mut command = Command::new(program);
49+
command.args(args).current_dir(&ctx.cwd);
50+
51+
if let Ok(real_node) = resolve_real_node_path() {
52+
command.env(REAL_NODE_ENV, &real_node);
53+
if let Some(path) = path_with_real_node_priority(&real_node, std::env::var_os("PATH")) {
54+
command.env("PATH", path);
55+
}
56+
}
57+
58+
let output = command.output().ok()?;
5359

5460
if !output.status.success() {
5561
return None;

src/core/runner.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ use super::{
77
shell::shell_escape,
88
types::ResolvedExecution,
99
};
10-
use crate::platform::node::{SHIM_ACTIVE_ENV, resolve_real_node_path};
10+
use crate::platform::node::{
11+
REAL_NODE_ENV, SHIM_ACTIVE_ENV, path_with_real_node_priority, resolve_real_node_path,
12+
};
1113

1214
pub fn run(exec: &ResolvedExecution, use_sfw: bool) -> Result<ExitCode> {
1315
if let Some(mode) = BatchMode::from_internal_program(&exec.program) {
@@ -24,6 +26,13 @@ pub fn run(exec: &ResolvedExecution, use_sfw: bool) -> Result<ExitCode> {
2426
.stdout(Stdio::inherit())
2527
.stderr(Stdio::inherit());
2628

29+
if let Ok(real_node) = resolve_real_node_path() {
30+
command.env(REAL_NODE_ENV, &real_node);
31+
if let Some(path) = path_with_real_node_priority(&real_node, std::env::var_os("PATH")) {
32+
command.env("PATH", path);
33+
}
34+
}
35+
2736
if passthrough {
2837
command.env(SHIM_ACTIVE_ENV, "1");
2938
}

src/platform/node.rs

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::{
2-
env, fs,
2+
env,
3+
ffi::OsString,
4+
fs,
35
path::{Path, PathBuf},
46
};
57

@@ -59,16 +61,7 @@ fn scan_path_for_real_node() -> Option<PathBuf> {
5961
.and_then(|path| path.parent().map(Path::to_path_buf));
6062
let candidates = which::which_all("node").ok()?;
6163
for candidate in candidates {
62-
if let Some(current_dir) = &current_dir
63-
&& let Some(parent) = candidate.parent()
64-
&& paths_equal(parent, current_dir)
65-
{
66-
continue;
67-
}
68-
69-
if let Some(current_exe) = &current_exe
70-
&& paths_equal(&candidate, current_exe)
71-
{
64+
if should_skip_node_candidate(&candidate, current_exe.as_deref(), current_dir.as_deref()) {
7265
continue;
7366
}
7467
return Some(candidate);
@@ -77,9 +70,108 @@ fn scan_path_for_real_node() -> Option<PathBuf> {
7770
None
7871
}
7972

73+
pub fn path_with_real_node_priority(
74+
real_node: &Path,
75+
current_path: Option<OsString>,
76+
) -> Option<OsString> {
77+
let real_node_dir = real_node.parent()?;
78+
let mut ordered = Vec::new();
79+
ordered.push(real_node_dir.to_path_buf());
80+
81+
if let Some(current_path) = current_path {
82+
ordered.extend(
83+
env::split_paths(&current_path).filter(|entry| !paths_equal(entry, real_node_dir)),
84+
);
85+
}
86+
87+
env::join_paths(ordered).ok()
88+
}
89+
90+
fn should_skip_node_candidate(
91+
candidate: &Path,
92+
current_exe: Option<&Path>,
93+
current_dir: Option<&Path>,
94+
) -> bool {
95+
if let Some(current_dir) = current_dir
96+
&& let Some(parent) = candidate.parent()
97+
&& paths_equal(parent, current_dir)
98+
{
99+
return true;
100+
}
101+
102+
if let Some(current_exe) = current_exe
103+
&& paths_equal(candidate, current_exe)
104+
{
105+
return true;
106+
}
107+
108+
matches!(
109+
candidate
110+
.canonicalize()
111+
.ok()
112+
.as_deref()
113+
.and_then(Path::file_name)
114+
.and_then(|name| name.to_str()),
115+
Some("hni") | Some("hni.exe")
116+
)
117+
}
118+
80119
fn paths_equal(a: &Path, b: &Path) -> bool {
81120
match (a.canonicalize(), b.canonicalize()) {
82121
(Ok(a), Ok(b)) => a == b,
83122
_ => a == b,
84123
}
85124
}
125+
126+
#[cfg(test)]
127+
mod tests {
128+
use super::*;
129+
use std::fs;
130+
use tempfile::tempdir;
131+
132+
#[test]
133+
fn path_with_real_node_priority_prepends_real_node_dir_once() {
134+
let path = path_with_real_node_priority(
135+
Path::new("/real/node"),
136+
Some(OsString::from("/shim:/real:/other")),
137+
)
138+
.unwrap();
139+
let entries = env::split_paths(&path).collect::<Vec<_>>();
140+
141+
assert_eq!(
142+
entries,
143+
vec![
144+
PathBuf::from("/real"),
145+
PathBuf::from("/shim"),
146+
PathBuf::from("/other"),
147+
]
148+
);
149+
}
150+
151+
#[cfg(unix)]
152+
#[test]
153+
fn skips_node_candidates_that_resolve_to_hni() {
154+
use std::os::unix::fs::symlink;
155+
156+
let dir = tempdir().unwrap();
157+
let release_dir = dir.path().join("release");
158+
let debug_dir = dir.path().join("debug");
159+
let shim_dir = dir.path().join("shim");
160+
161+
fs::create_dir_all(&release_dir).unwrap();
162+
fs::create_dir_all(&debug_dir).unwrap();
163+
fs::create_dir_all(&shim_dir).unwrap();
164+
165+
let release_hni = release_dir.join("hni");
166+
let debug_hni = debug_dir.join("hni");
167+
fs::write(&release_hni, b"release").unwrap();
168+
fs::write(&debug_hni, b"debug").unwrap();
169+
symlink(&release_hni, shim_dir.join("node")).unwrap();
170+
171+
assert!(should_skip_node_candidate(
172+
&shim_dir.join("node"),
173+
Some(&debug_hni),
174+
Some(&debug_dir),
175+
));
176+
}
177+
}

tests/init_contract.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,98 @@ fn bash_init_gives_node_shim_precedence_and_preserves_real_node() {
146146
});
147147
}
148148

149+
#[cfg(unix)]
150+
#[test]
151+
fn bash_init_keeps_package_manager_shebangs_on_real_node() {
152+
support::with_env_lock(|| {
153+
use std::os::unix::fs::PermissionsExt;
154+
155+
let Some(bash) = which::which("bash").ok() else {
156+
return;
157+
};
158+
159+
let dir = tempfile::tempdir().unwrap();
160+
let hni_bin = dir.path().join("hni-bin");
161+
let real_node_bin = dir.path().join("real-node-bin");
162+
let pm_bin = dir.path().join("pm-bin");
163+
let fake_home = dir.path().join("home");
164+
let fake_config = dir.path().join("config");
165+
let shim_log = dir.path().join("shim.log");
166+
167+
fs::create_dir_all(&hni_bin).unwrap();
168+
fs::create_dir_all(&real_node_bin).unwrap();
169+
fs::create_dir_all(&pm_bin).unwrap();
170+
fs::create_dir_all(&fake_home).unwrap();
171+
fs::create_dir_all(&fake_config).unwrap();
172+
173+
let source_exe = support::hni_executable_path();
174+
let copied_hni = hni_bin.join("hni");
175+
fs::copy(&source_exe, &copied_hni).unwrap();
176+
set_executable_if_needed(&copied_hni);
177+
178+
let fake_node = real_node_bin.join("node");
179+
fs::write(
180+
&fake_node,
181+
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n printf 'v99.0.0\\n'\n exit 0\nfi\nprintf '99.0.0\\n'\n",
182+
)
183+
.unwrap();
184+
set_executable_if_needed(&fake_node);
185+
186+
let bad_shim_node = hni_bin.join("node");
187+
fs::write(
188+
&bad_shim_node,
189+
format!(
190+
"#!/bin/sh\nprintf 'shim-used\\n' >> '{}'\nexit 97\n",
191+
shim_log.display()
192+
),
193+
)
194+
.unwrap();
195+
set_executable_if_needed(&bad_shim_node);
196+
197+
let fake_npm = pm_bin.join("npm");
198+
fs::write(&fake_npm, "#!/usr/bin/env node\nconsole.log('npm');\n").unwrap();
199+
set_executable_if_needed(&fake_npm);
200+
201+
let base_path = format!(
202+
"{}:{}:{}",
203+
pm_bin.display(),
204+
real_node_bin.display(),
205+
std::env::var("PATH").unwrap_or_default()
206+
);
207+
let script = format!(
208+
"eval \"$({} init bash)\"\n{} --version\n",
209+
copied_hni.display(),
210+
copied_hni.display()
211+
);
212+
213+
let output = Command::new(bash)
214+
.arg("-c")
215+
.arg(script)
216+
.env("PATH", base_path)
217+
.env("HOME", &fake_home)
218+
.env("XDG_CONFIG_HOME", &fake_config)
219+
.env("APPDATA", &fake_config)
220+
.output()
221+
.expect("failed to run bash version flow");
222+
223+
assert!(output.status.success());
224+
let stdout = String::from_utf8_lossy(&output.stdout);
225+
assert!(stdout.contains("node v99.0.0"));
226+
assert!(stdout.contains("agent npm (99.0.0)"));
227+
assert!(stdout.contains("global npm (99.0.0)"));
228+
assert!(
229+
!shim_log.exists()
230+
|| fs::read_to_string(&shim_log)
231+
.unwrap_or_default()
232+
.trim()
233+
.is_empty()
234+
);
235+
236+
let perms = fs::metadata(&bad_shim_node).unwrap().permissions();
237+
assert_eq!(perms.mode() & 0o111, 0o111);
238+
});
239+
}
240+
149241
fn set_executable_if_needed(path: &Path) {
150242
#[cfg(unix)]
151243
{

0 commit comments

Comments
 (0)