Skip to content

Commit a4b727f

Browse files
authored
feat(pre_use): warn when another node, e.g. pnpm's, shadows vfox in PATH (#38)
1 parent 0e8034a commit a4b727f

5 files changed

Lines changed: 200 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ See [docs/source-build.md](docs/source-build.md) for details.
5555

5656
See [docs/lts.md](docs/lts.md) for details.
5757

58+
### pnpm
59+
60+
See [docs/pnpm.md](docs/pnpm.md) for details.
61+
5862
### Testing
5963

6064
See [docs/e2e.md](docs/e2e.md) for details.

‎docs/pnpm.md‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# pnpm
2+
3+
pnpm as a package manager, for example from `npm install -g pnpm`, works
4+
next to this plugin. The conflict starts when pnpm also installs Node.js:
5+
`pnpm runtime set node <version> -g` (or the deprecated
6+
`pnpm env use --global <version>`) puts a second `node` into
7+
`$PNPM_HOME/bin`. Only the `node` that comes first in `PATH` runs, and with
8+
the shell setup pnpm writes that is pnpm's, whatever `vfox use` selected.
9+
10+
Observed with pnpm 12.14.0 and vfox 1.0.12 in bash on Ubuntu.
11+
12+
## Symptoms
13+
14+
- `vfox use nodejs@24.21.0` prints `Now using nodejs@24.21.0` and
15+
`vfox current nodejs` agrees, but `node --version` prints the
16+
pnpm-managed version. Global, session and project scope are all affected.
17+
- `type -a node` lists `$PNPM_HOME/bin/node` before the vfox path.
18+
- In a directory with `.nvmrc` or `.node-version`, `node` runs a copy pnpm
19+
downloads on first use, even when vfox has that version installed.
20+
- `npm install -g <pkg>` succeeds but the command is not found. pnpm's
21+
runtime ships no npm, so vfox's `npm` runs on pnpm's `node` and installs
22+
into pnpm's copy of Node.js under `$PNPM_HOME`.
23+
24+
## Why
25+
26+
1. `pnpm runtime set node -g` refuses to run until `$PNPM_HOME/bin` is on
27+
`PATH` (`ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH`). The fix it suggests,
28+
`pnpm setup`, adds a block at the end of the shell rc file, below
29+
`eval "$(vfox activate bash)"`, that prepends `$PNPM_HOME/bin` to `PATH`.
30+
2. At every prompt `vfox env` keeps the `PATH` entries that come before its
31+
first own path ahead of all SDK paths, so that an activated Python
32+
virtualenv keeps priority. `$PNPM_HOME/bin` is such an entry.
33+
3. That `node` is pnpm's shim: it runs pnpm's global Node.js, or the version
34+
the current project pins through `devEngines.runtime`, `engines.runtime`,
35+
`.node-version` or `.nvmrc`
36+
([project-aware global bins](https://pnpm.io/global-packages#project-aware-global-bins)).
37+
pnpm's `globalShims` setting and `PNPM_SHIM_BYPASS=1` only turn off the
38+
project lookup; `node` still runs pnpm's global Node.js, not vfox's.
39+
40+
`vfox use nodejs@<version>` prints a note when a `node` sits ahead of vfox
41+
this way, whichever tool put it there:
42+
43+
```text
44+
Note: /home/user/.local/share/pnpm/bin/node comes before vfox in PATH, so `node` will not run nodejs@24.21.0. See https://github.com/version-fox/vfox-nodejs/blob/main/docs/pnpm.md
45+
```
46+
47+
## Keeping vfox in charge of `node`
48+
49+
Remove the Node.js that pnpm installed globally. pnpm keeps working, since
50+
`pnpm setup` copied it into `$PNPM_HOME`:
51+
52+
```sh
53+
pnpm remove -g node
54+
```
55+
56+
Activating vfox below pnpm's block in the rc file is not enough: pnpm's
57+
block prepends `$PNPM_HOME/bin` again unless it already comes first, so
58+
nested shells put pnpm's `node` back in front.
59+
60+
Projects that pin a runtime with `devEngines.runtime` keep it for scripts:
61+
after `pnpm install`, `pnpm run` and `pnpm exec` use the pinned Node.js from
62+
`node_modules/.bin`, while a bare `node` follows vfox.
63+
64+
To let pnpm manage Node.js instead, do not select `nodejs` with vfox in the
65+
same shell.

‎hooks/pre_use.lua‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ function PLUGIN:PreUse(ctx)
2626
version = shorthands[version]
2727
end
2828

29+
-- vfox reports the switch even when another `node` keeps winning PATH
30+
-- lookup, so say so here instead of leaving `node --version` to
31+
-- contradict it.
32+
local shadowing = util.shadowing_node()
33+
if version ~= nil and shadowing ~= nil then
34+
print(
35+
"Note: "
36+
.. shadowing
37+
.. " comes before vfox in PATH, so `node` will not run nodejs@"
38+
.. version
39+
.. ". See "
40+
.. util.PnpmDocUrl
41+
)
42+
end
43+
2944
--- return the version information
3045
return {
3146
version = version,

‎lib/util.lua‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ UTIL.FileName = "node-v%s-%s-%s%s"
1919
UTIL.VersionSourceUrl = "/index.json"
2020
UTIL.UnofficialBaseUrl = "https://unofficial-builds.nodejs.org/download/release"
2121
UTIL.ReadmeUrl = "https://github.com/version-fox/vfox-nodejs/blob/main/README.md"
22+
UTIL.PnpmDocUrl = "https://github.com/version-fox/vfox-nodejs/blob/main/docs/pnpm.md"
2223

2324
function UTIL.getBaseUrl()
2425
local mirror = os.getenv("VFOX_NODEJS_MIRROR")
@@ -120,6 +121,51 @@ function UTIL.libstdcxx_hint()
120121
return "the libstdc++ system package (libstdcpp on OpenWrt)"
121122
end
122123

124+
--- Whether a PATH entry belongs to vfox: like vfox's own check, one of its
125+
--- segments is `.vfox` (or the legacy `.version-fox`).
126+
local function is_vfox_path(dir)
127+
for segment in string.gmatch(dir, "[^/\\]+") do
128+
if segment == ".vfox" or segment == ".version-fox" then
129+
return true
130+
end
131+
end
132+
return false
133+
end
134+
135+
--- First `node` that `vfox env` keeps ahead of the SDK paths. vfox leaves
136+
--- PATH entries before its first own path in front, as user-injected (so an
137+
--- activated virtualenv keeps priority); a `node` there, such as the one
138+
--- `pnpm runtime set node -g` writes to `$PNPM_HOME/bin`, shadows the
139+
--- version `vfox use` selects. With no vfox path on PATH yet, vfox puts its
140+
--- paths first and nothing shadows them.
141+
--- See https://github.com/version-fox/vfox-nodejs/blob/main/docs/pnpm.md
142+
--- @return string|nil path of the shadowing executable
143+
function UTIL.shadowing_node()
144+
local path = os.getenv("PATH")
145+
if path == nil or path == "" then
146+
return nil
147+
end
148+
local windows = RUNTIME ~= nil and RUNTIME.osType == "windows"
149+
local separator = windows and ";" or ":"
150+
local names = windows and { "\\node.exe", "\\node.cmd" } or { "/node" }
151+
local ahead = {}
152+
for dir in string.gmatch(path, "[^" .. separator .. "]+") do
153+
if is_vfox_path(dir) then
154+
for _, candidate_dir in ipairs(ahead) do
155+
for _, name in ipairs(names) do
156+
local candidate = (candidate_dir:gsub("[/\\]+$", "")) .. name
157+
if UTIL.file_exists(candidate) then
158+
return candidate
159+
end
160+
end
161+
end
162+
return nil
163+
end
164+
table.insert(ahead, dir)
165+
end
166+
return nil
167+
end
168+
123169
function UTIL.compare_versions(v1o, v2o)
124170
local v1 = v1o.version
125171
local v2 = v2o.version

‎tests/hooks_test.lua‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,5 +287,75 @@ test("legacy latest_available resolves against the index", function()
287287
os.remove(path)
288288
assert(result.version == "22.23.3", tostring(result.version))
289289
end)
290+
-- PreUse notes a `node` that vfox keeps ahead of its own paths, such as the
291+
-- one `pnpm runtime set node -g` writes to $PNPM_HOME/bin.
292+
dofile("hooks/pre_use.lua")
293+
local function with_path(path, existing, fn)
294+
local saved_getenv, saved_exists, saved_print = os.getenv, util.file_exists, print
295+
local printed = {}
296+
os.getenv = function(key)
297+
if key == "PATH" then
298+
return path
299+
end
300+
return saved_getenv(key)
301+
end
302+
util.file_exists = function(p)
303+
return existing[p] == true
304+
end
305+
print = function(line)
306+
table.insert(printed, line)
307+
end
308+
local ok, err = pcall(fn)
309+
os.getenv, util.file_exists, print = saved_getenv, saved_exists, saved_print
310+
assert(ok, err)
311+
return printed
312+
end
313+
local installed = { ["24.21.0"] = { version = "24.21.0" }, ["22.23.3"] = { version = "22.23.3" } }
314+
test("pre_use notes a node ahead of vfox", function()
315+
RUNTIME = { osType = "linux", archType = "amd64" }
316+
local printed = with_path("/home/u/.local/share/pnpm/bin:/home/u/.vfox/sdks/nodejs/bin:/usr/bin", {
317+
["/home/u/.local/share/pnpm/bin/node"] = true,
318+
["/home/u/.vfox/sdks/nodejs/bin/node"] = true,
319+
}, function()
320+
assert(PLUGIN:PreUse({ version = "24", installedSdks = installed }).version == "24.21.0")
321+
end)
322+
assert(#printed == 1, "expected one note")
323+
assert(printed[1]:find("/home/u/.local/share/pnpm/bin/node", 1, true), printed[1])
324+
assert(printed[1]:find("nodejs@24.21.0", 1, true), printed[1])
325+
assert(printed[1]:find("docs/pnpm.md", 1, true), printed[1])
326+
end)
327+
test("pre_use stays quiet when vfox comes first", function()
328+
RUNTIME = { osType = "linux", archType = "amd64" }
329+
local printed = with_path("/home/u/.vfox/sdks/nodejs/bin:/home/u/.local/share/pnpm/bin", {
330+
["/home/u/.local/share/pnpm/bin/node"] = true,
331+
}, function()
332+
assert(PLUGIN:PreUse({ version = "22.23.3", installedSdks = installed }).version == "22.23.3")
333+
end)
334+
assert(#printed == 0, tostring(printed[1]))
335+
end)
336+
test("shadowing_node follows vfox's PATH split", function()
337+
RUNTIME = { osType = "linux", archType = "amd64" }
338+
local existing = { ["/opt/node/bin/node"] = true }
339+
with_path("/opt/node/bin/:/home/u/.version-fox/temp/1/nodejs/bin", existing, function()
340+
assert(util.shadowing_node() == "/opt/node/bin/node", tostring(util.shadowing_node()))
341+
end)
342+
-- Behind another SDK's vfox path the entry ranks below every vfox path.
343+
with_path("/home/u/.vfox/sdks/java/bin:/opt/node/bin:/home/u/.vfox/sdks/nodejs/bin", existing, function()
344+
assert(util.shadowing_node() == nil)
345+
end)
346+
-- No vfox path yet: vfox will put its paths first.
347+
with_path("/opt/node/bin:/usr/bin", existing, function()
348+
assert(util.shadowing_node() == nil)
349+
end)
350+
end)
351+
test("shadowing_node on windows", function()
352+
RUNTIME = { osType = "windows", archType = "amd64" }
353+
local pnpm = "C:\\Users\\u\\AppData\\Local\\pnpm\\bin"
354+
with_path(pnpm .. ";C:\\Users\\u\\.vfox\\sdks\\nodejs;C:\\Windows", {
355+
[pnpm .. "\\node.exe"] = true,
356+
}, function()
357+
assert(util.shadowing_node() == pnpm .. "\\node.exe", tostring(util.shadowing_node()))
358+
end)
359+
end)
290360
print(state.passed .. " passed, " .. state.failed .. " failed")
291361
assert(state.failed == 0)

0 commit comments

Comments
 (0)