Skip to content

Commit 8cd9003

Browse files
authored
Merge pull request #359 from quarto-dev/feature/bd-e1ger90c-support-fnm-nvm-windows
Support fnm and nvm-windows Node discovery on Windows
2 parents 51cf370 + 6cecf76 commit 8cd9003

3 files changed

Lines changed: 380 additions & 2 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# fnm + nvm-windows Node discovery for `q2 mcp` (bd-e1ger90c)
2+
3+
## Overview
4+
5+
`crates/quarto-mcp-launcher/src/node.rs` resolves an ambient Node.js
6+
for `q2 mcp`. Layer 3 (`default_well_known()`) is a fallback probe of
7+
known install locations, used only when `QUARTO_NODE` is unset and
8+
`node` isn't on `PATH`. On Windows this layer only checks Program
9+
Files and Volta — no fnm, no nvm-windows.
10+
11+
**Confirmed broken (bd-e1ger90c, comment c-5vnn5yn2):** with PATH
12+
stripped to Windows system dirs (simulating a GUI-launched MCP host
13+
like Claude Desktop, which doesn't source a shell profile), `q2 mcp`
14+
fails to find Node even with fnm installed and a version set as
15+
default. fnm mutates PATH only inside an interactive shell
16+
(`fnm env`), never persists it — so a GUI host genuinely never sees
17+
it. This is exactly the module's own stated flagship use case
18+
(see the file's module doc, lines 1–6).
19+
20+
nvm-windows is not similarly broken today: it persists its active
21+
version to the user's PATH via the Windows registry, so most GUI
22+
apps inherit it. Adding nvm-windows support here is defense-in-depth,
23+
not a confirmed-broken fix.
24+
25+
**Latent Windows warning fixed as a side effect:** `highest_version_node`
26+
is currently uncalled on Windows (its only caller is the `#[cfg(unix)]`
27+
branch of `default_well_known`) and is *not* cfg-gated, so a Windows
28+
build emits `warning: function highest_version_node is never used`.
29+
CI is unix-only today so `-D warnings` never bites, but it is a real
30+
latent break. Adding Windows call sites makes the function live on all
31+
platforms and removes the warning. (Earlier draft of this plan wrongly
32+
described a `#[cfg(unix)]` gate on the function — there is none; the
33+
symptom is dead_code, not a gate.)
34+
35+
**Verified layouts** (checked live on this machine, not assumed —
36+
the strand description's guess of `installation/bin/node.exe` for
37+
fnm-on-Windows was wrong):
38+
39+
- fnm on Windows, base `%APPDATA%\fnm` (via `dirs::config_dir()`):
40+
- `node-versions\vX.Y.Z\installation\node.exe`**no `bin`
41+
subdir**, unlike unix fnm.
42+
- `aliases\default` is a junction pointing straight at
43+
`node-versions\vX.Y.Z\installation` (verified via
44+
`ls -la $APPDATA/fnm/aliases/default`), so the default-alias
45+
candidate is `aliases\default\node.exe`.
46+
- nvm-windows, root at `%NVM_HOME%` (the env var nvm-windows itself
47+
sets and is the only reliable way to find the root — unlike unix
48+
nvm, there's no fixed `~/.nvm`-style default):
49+
- `vX.Y.Z\node.exe`**flat**, no subdir at all.
50+
- Known gap, accepted: a scoop-packaged nvm-windows install
51+
redirects its actual root via `settings.txt`'s `root:` line
52+
(confirmed on this machine — `NVM_HOME` points at the scoop
53+
shim, not the version root). Parsing `settings.txt` is out of
54+
scope (YAGNI — nvm-windows isn't the confirmed-broken case;
55+
`NVM_HOME` alone covers the common installer-based setup).
56+
57+
**Approach (decided 2026-07-02, Option B):** keep
58+
`highest_version_node`'s 1-argument signature. Its internal hardcoded
59+
sub-path list becomes a **superset** covering all four layouts:
60+
`["bin/node", "installation/bin/node", "installation/node.exe",
61+
"node.exe"]`. Then add Windows call sites for the fnm base
62+
(`dirs::config_dir()/fnm/node-versions`) and the nvm-windows root
63+
(`%NVM_HOME%`). No signature change, no unix call-site churn.
64+
65+
Why the superset is safe across platforms: a sub-path that doesn't
66+
match the current OS's layout simply isn't a file, so it's skipped.
67+
Unix binaries are `node` (not `node.exe`) under `bin/` or
68+
`installation/bin/`, so the two `.exe` subs never false-match on unix;
69+
Windows binaries carry `.exe`, so the two unix subs never false-match
70+
on Windows. Cost is a couple of extra `is_file` stats per version
71+
dir — negligible, and existence is already checked lazily.
72+
73+
The rejected alternative (Option A) was to parameterize
74+
`sub_paths: &[&str]` and pass an explicit per-manager list at each of
75+
the five call sites. More self-documenting per site, but more churn
76+
for no behavioral gain; Option B is the smaller reasonable change.
77+
78+
## Global constraints
79+
80+
- No behavior change on unix: the two unix subs stay first in the
81+
list, so an existing unix install resolves to exactly the same path
82+
it does today.
83+
- `highest_version_node`'s directory-scan logic stays OS-agnostic so
84+
tests exercise both unix-style and Windows-style layouts on any
85+
platform (it's just `fs::read_dir` + path joins, never
86+
`probe_version`/`Command::new`).
87+
- Match existing style: hardcoded well-known paths, no new config/
88+
env-var indirection beyond what nvm-windows/fnm themselves require
89+
(`NVM_HOME`, `dirs::config_dir()`).
90+
91+
## Checklist
92+
93+
- [ ] Write failing tests for the new layouts (flat `node.exe`,
94+
fnm-Windows `installation/node.exe`) — they RED against the
95+
current 2-entry sub-path list by returning `None`
96+
- [ ] Widen the internal sub-path list to the 4-entry superset
97+
- [ ] Add fnm-on-Windows and nvm-windows probing to the
98+
`#[cfg(windows)]` branch of `default_well_known()`
99+
- [ ] Run `cargo nextest run -p quarto-mcp-launcher` and confirm new
100+
tests pass and the Windows dead_code warning is gone
101+
- [ ] Run `cargo xtask verify --skip-hub-build` (Rust-only change)
102+
- [ ] Manually re-run the bd-e1ger90c repro (fnm active, PATH
103+
stripped) and confirm `q2 mcp --launcher-info` now finds Node
104+
- [ ] Comment the fix + repro confirmation on bd-e1ger90c, then close
105+
106+
## Details
107+
108+
### Step 1 — failing tests first (meaningful RED)
109+
110+
Add to the `#[cfg(test)] mod tests` block in
111+
`crates/quarto-mcp-launcher/src/node.rs`. These call the *existing*
112+
1-arg signature, so they compile — and the two new-layout tests fail
113+
their **assertion** (the current list has neither `node.exe` nor
114+
`installation/node.exe`, so `highest_version_node` returns `None`).
115+
That's the meaningful RED: a wrong return value, not a compile error.
116+
117+
```rust
118+
use std::fs;
119+
120+
fn touch(path: &Path) {
121+
fs::create_dir_all(path.parent().unwrap()).unwrap();
122+
fs::write(path, b"").unwrap();
123+
}
124+
125+
#[test]
126+
fn highest_version_node_picks_newest_unix_style() {
127+
let dir = tempfile::tempdir().unwrap();
128+
touch(&dir.path().join("v22.1.0/bin/node"));
129+
touch(&dir.path().join("v24.15.0/bin/node"));
130+
assert_eq!(
131+
highest_version_node(dir.path()),
132+
Some(dir.path().join("v24.15.0/bin/node"))
133+
);
134+
}
135+
136+
#[test]
137+
fn highest_version_node_picks_newest_fnm_unix_style() {
138+
let dir = tempfile::tempdir().unwrap();
139+
touch(&dir.path().join("v24.1.0/installation/bin/node"));
140+
touch(&dir.path().join("v20.9.0/installation/bin/node"));
141+
assert_eq!(
142+
highest_version_node(dir.path()),
143+
Some(dir.path().join("v24.1.0/installation/bin/node"))
144+
);
145+
}
146+
147+
#[test]
148+
fn highest_version_node_picks_newest_flat_windows_style() {
149+
// nvm-windows layout: <root>/vX.Y.Z/node.exe, no subdir.
150+
let dir = tempfile::tempdir().unwrap();
151+
touch(&dir.path().join("v22.11.0/node.exe"));
152+
touch(&dir.path().join("v24.18.0/node.exe"));
153+
assert_eq!(
154+
highest_version_node(dir.path()),
155+
Some(dir.path().join("v24.18.0/node.exe"))
156+
);
157+
}
158+
159+
#[test]
160+
fn highest_version_node_picks_newest_fnm_windows_style() {
161+
// fnm-on-Windows layout: <root>/vX.Y.Z/installation/node.exe.
162+
let dir = tempfile::tempdir().unwrap();
163+
touch(&dir.path().join("v24.18.0/installation/node.exe"));
164+
assert_eq!(
165+
highest_version_node(dir.path()),
166+
Some(dir.path().join("v24.18.0/installation/node.exe"))
167+
);
168+
}
169+
170+
#[test]
171+
fn highest_version_node_ignores_non_version_dirs_and_missing_root() {
172+
let dir = tempfile::tempdir().unwrap();
173+
touch(&dir.path().join("not-a-version/node.exe"));
174+
assert_eq!(highest_version_node(dir.path()), None);
175+
176+
let missing = dir.path().join("does-not-exist");
177+
assert_eq!(highest_version_node(&missing), None);
178+
}
179+
```
180+
181+
Run: `cargo nextest run -p quarto-mcp-launcher`
182+
Expected RED: `highest_version_node_picks_newest_flat_windows_style`
183+
and `highest_version_node_picks_newest_fnm_windows_style` fail
184+
(`None` vs the expected path). The two unix-style tests already pass;
185+
the ignore/missing test passes.
186+
187+
### Step 2 — widen the sub-path list to the superset
188+
189+
In `highest_version_node` (currently
190+
`crates/quarto-mcp-launcher/src/node.rs:240`), replace:
191+
192+
```rust
193+
// nvm: <root>/vX.Y.Z/bin/node; fnm: <root>/vX.Y.Z/installation/bin/node
194+
for sub in ["bin/node", "installation/bin/node"] {
195+
```
196+
197+
with:
198+
199+
```rust
200+
// Layouts, unix subs first (unix behavior is unchanged):
201+
// unix nvm <root>/vX.Y.Z/bin/node
202+
// unix fnm <root>/vX.Y.Z/installation/bin/node
203+
// fnm-win <root>/vX.Y.Z/installation/node.exe
204+
// nvm-win <root>/vX.Y.Z/node.exe (flat)
205+
// A sub for the wrong OS simply isn't a file and is skipped.
206+
for sub in [
207+
"bin/node",
208+
"installation/bin/node",
209+
"installation/node.exe",
210+
"node.exe",
211+
] {
212+
```
213+
214+
Run: `cargo nextest run -p quarto-mcp-launcher`
215+
Expected GREEN: all five tests pass. This is the meaningful GREEN —
216+
the two previously-red Windows-layout assertions now resolve.
217+
218+
### Step 3 — add Windows probing
219+
220+
In `default_well_known()`, extend the `#[cfg(windows)]` branch
221+
(currently `crates/quarto-mcp-launcher/src/node.rs:216-224`) to add,
222+
after the existing Volta push:
223+
224+
```rust
225+
// fnm on Windows: <config>/fnm/{aliases/default,node-versions}.
226+
// aliases/default is a junction straight to the version's
227+
// `installation` dir (no `bin` subdir on Windows).
228+
if let Some(config) = dirs::config_dir() {
229+
let fnm_base = config.join("fnm");
230+
paths.push(fnm_base.join("aliases/default/node.exe"));
231+
paths.extend(highest_version_node(&fnm_base.join("node-versions")));
232+
}
233+
// nvm-windows: root is wherever NVM_HOME points (there's no
234+
// fixed default the way unix nvm has ~/.nvm), flat
235+
// <root>/vX.Y.Z/node.exe layout. A scoop-packaged install
236+
// redirects its real root via settings.txt's `root:` line,
237+
// which NVM_HOME alone won't see -- accepted gap; nvm-windows
238+
// already persists its active version to PATH via the registry
239+
// so this probe is defense-in-depth, not the confirmed-broken
240+
// case.
241+
if let Some(nvm_home) = std::env::var_os("NVM_HOME") {
242+
paths.extend(highest_version_node(&PathBuf::from(nvm_home)));
243+
}
244+
```
245+
246+
This also makes `highest_version_node` live on Windows, removing the
247+
dead_code warning.
248+
249+
Run: `cargo nextest run -p quarto-mcp-launcher`
250+
Expected: PASS, and the Windows build no longer warns.
251+
252+
### Step 4 — full verification
253+
254+
```bash
255+
cargo nextest run -p quarto-mcp-launcher
256+
cargo xtask verify --skip-hub-build
257+
```
258+
259+
`--skip-hub-build` is correct — this touches only
260+
`quarto-mcp-launcher`, which nothing in `wasm-quarto-hub-client`'s
261+
dependency closure depends on.
262+
263+
### Step 5 — manual repro re-check
264+
265+
Re-run the exact reproduction from bd-e1ger90c comment c-5vnn5yn2:
266+
fnm active with Node 24.18.0 set as default, PATH stripped to
267+
Windows system dirs only:
268+
269+
```bash
270+
eval "$(fnm env --shell bash)"
271+
fnm default 24
272+
PATH="/c/Windows/system32:/c/Windows" ./target/debug/q2.exe mcp --launcher-info
273+
```
274+
275+
Expected: reports a found Node (the fnm well-known path), not
276+
`node: NOT FOUND`. Record actual output in the braid comment.
277+
278+
Note the build chain: `q2 mcp` needs the hub-mcp bundle embedded, but
279+
`--launcher-info` only reports discovery and does not run the bundle,
280+
so a plain `cargo build --bin q2` is enough to exercise the fix.
281+
282+
### Step 6 — close out
283+
284+
Comment the fix + repro confirmation on bd-e1ger90c and close it —
285+
only after Step 5's manual repro actually shows Node found (project
286+
end-to-end-verification rule: unit tests exercise
287+
`highest_version_node` directly, not the real `q2 mcp` discovery path).

crates/quarto-mcp-launcher/src/node.rs

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,25 @@ fn default_well_known() -> Vec<PathBuf> {
221221
if let Some(home) = &home {
222222
paths.push(home.join("AppData/Local/Volta/bin/node.exe"));
223223
}
224+
// fnm on Windows: <config>/fnm/{aliases/default,node-versions}.
225+
// aliases/default is a junction straight to the version's
226+
// `installation` dir (no `bin` subdir on Windows).
227+
if let Some(config) = dirs::config_dir() {
228+
let fnm_base = config.join("fnm");
229+
paths.push(fnm_base.join("aliases/default/node.exe"));
230+
paths.extend(highest_version_node(&fnm_base.join("node-versions")));
231+
}
232+
// nvm-windows: root is wherever NVM_HOME points (there's no
233+
// fixed default the way unix nvm has ~/.nvm), flat
234+
// <root>/vX.Y.Z/node.exe layout. A scoop-packaged install
235+
// redirects its real root via settings.txt's `root:` line,
236+
// which NVM_HOME alone won't see -- accepted gap; nvm-windows
237+
// already persists its active version to PATH via the registry
238+
// so this probe is defense-in-depth, not the confirmed-broken
239+
// case.
240+
if let Some(nvm_home) = std::env::var_os("NVM_HOME") {
241+
paths.extend(highest_version_node(&PathBuf::from(nvm_home)));
242+
}
224243
}
225244
let _ = home;
226245
paths
@@ -236,8 +255,18 @@ fn highest_version_node(root: &Path) -> Option<PathBuf> {
236255
let Some(version) = parse_version(&name.to_string_lossy()) else {
237256
continue;
238257
};
239-
// nvm: <root>/vX.Y.Z/bin/node; fnm: <root>/vX.Y.Z/installation/bin/node
240-
for sub in ["bin/node", "installation/bin/node"] {
258+
// Layouts, unix subs first (unix behavior is unchanged):
259+
// unix nvm <root>/vX.Y.Z/bin/node
260+
// unix fnm <root>/vX.Y.Z/installation/bin/node
261+
// fnm-win <root>/vX.Y.Z/installation/node.exe
262+
// nvm-win <root>/vX.Y.Z/node.exe (flat)
263+
// A sub for the wrong OS simply isn't a file and is skipped.
264+
for sub in [
265+
"bin/node",
266+
"installation/bin/node",
267+
"installation/node.exe",
268+
"node.exe",
269+
] {
241270
let candidate = entry.path().join(sub);
242271
if candidate.is_file() && best.as_ref().is_none_or(|(v, _)| version > *v) {
243272
best = Some((version, candidate.clone()));
@@ -263,4 +292,64 @@ mod tests {
263292
assert_eq!(parse_version(""), None);
264293
assert_eq!(parse_version("node"), None);
265294
}
295+
296+
fn touch(path: &Path) {
297+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
298+
std::fs::write(path, b"").unwrap();
299+
}
300+
301+
#[test]
302+
fn highest_version_node_picks_newest_unix_style() {
303+
let dir = tempfile::tempdir().unwrap();
304+
touch(&dir.path().join("v22.1.0/bin/node"));
305+
touch(&dir.path().join("v24.15.0/bin/node"));
306+
assert_eq!(
307+
highest_version_node(dir.path()),
308+
Some(dir.path().join("v24.15.0/bin/node"))
309+
);
310+
}
311+
312+
#[test]
313+
fn highest_version_node_picks_newest_fnm_unix_style() {
314+
let dir = tempfile::tempdir().unwrap();
315+
touch(&dir.path().join("v24.1.0/installation/bin/node"));
316+
touch(&dir.path().join("v20.9.0/installation/bin/node"));
317+
assert_eq!(
318+
highest_version_node(dir.path()),
319+
Some(dir.path().join("v24.1.0/installation/bin/node"))
320+
);
321+
}
322+
323+
#[test]
324+
fn highest_version_node_picks_newest_flat_windows_style() {
325+
// nvm-windows layout: <root>/vX.Y.Z/node.exe, no subdir.
326+
let dir = tempfile::tempdir().unwrap();
327+
touch(&dir.path().join("v22.11.0/node.exe"));
328+
touch(&dir.path().join("v24.18.0/node.exe"));
329+
assert_eq!(
330+
highest_version_node(dir.path()),
331+
Some(dir.path().join("v24.18.0/node.exe"))
332+
);
333+
}
334+
335+
#[test]
336+
fn highest_version_node_picks_newest_fnm_windows_style() {
337+
// fnm-on-Windows layout: <root>/vX.Y.Z/installation/node.exe.
338+
let dir = tempfile::tempdir().unwrap();
339+
touch(&dir.path().join("v24.18.0/installation/node.exe"));
340+
assert_eq!(
341+
highest_version_node(dir.path()),
342+
Some(dir.path().join("v24.18.0/installation/node.exe"))
343+
);
344+
}
345+
346+
#[test]
347+
fn highest_version_node_ignores_non_version_dirs_and_missing_root() {
348+
let dir = tempfile::tempdir().unwrap();
349+
touch(&dir.path().join("not-a-version/node.exe"));
350+
assert_eq!(highest_version_node(dir.path()), None);
351+
352+
let missing = dir.path().join("does-not-exist");
353+
assert_eq!(highest_version_node(&missing), None);
354+
}
266355
}

0 commit comments

Comments
 (0)