|
| 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). |
0 commit comments