# Node.js v3.0 Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add `node_version:` field to `envknit.yaml` environments, resolve the correct Node.js binary via mise→fnm→nvm→system, and inject it into `PATH` during `envknit run`.

**Architecture:** New `node_resolver.rs` mirrors `python_resolver.rs` structure exactly — same version matching logic, same resolution chain pattern, same fallback warning style. `run.rs` prepends the resolved node bin dir to `PATH` so node/npm/npx all switch atomically. `doctor.rs` and `check.rs` get per-env node_version diagnostic items (Warn, not Fail).

**Tech Stack:** Rust, `std::process::Command`, `dirs_next` (home dir), existing `version_matches`/`compare_version_strings` logic from `python_resolver.rs`.

---

### Task 1: Add `node_version` to config schema

**Files:**
- Modify: `crates/envknit-cli/src/config.rs:40-50`

**Step 1: Write the failing test**

Add inside the existing `mod tests` block in `config.rs`:

```rust
#[test]
fn test_node_version_round_trips() {
    let dir = tmpdir("node_ver");
    let yaml = "environments:\n  frontend:\n    node_version: '20.11'\n    packages: []\n";
    let path = dir.join(CONFIG_FILE);
    std::fs::write(&path, yaml).unwrap();
    let cfg = Config::load(&path).unwrap();
    assert_eq!(
        cfg.environments["frontend"].node_version.as_deref(),
        Some("20.11")
    );
}
```

**Step 2: Run test to verify it fails**

```bash
cd /home/woogwangsim/git_repos/EnvKnit
source ~/.cargo/env
cargo test --quiet config::tests::test_node_version_round_trips 2>&1 | tail -5
```
Expected: compile error — `node_version` field not found on `EnvironmentConfig`.

**Step 3: Add `node_version` field to `EnvironmentConfig`**

In `config.rs`, find the `EnvironmentConfig` struct and add after `python_version`:

```rust
#[serde(skip_serializing_if = "Option::is_none")]
pub node_version: Option<String>,
```

Also find every `EnvironmentConfig { ... }` literal in tests and add `node_version: None`.
There is one in `test_config_save_and_load` — add it there.

**Step 4: Run tests**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all tests pass, including the new `test_node_version_round_trips`.

**Step 5: Commit**

```bash
git add crates/envknit-cli/src/config.rs
git commit -m "feat: add node_version field to EnvironmentConfig"
```

---

### Task 2: Add `node_version_manager` to GlobalConfig

**Files:**
- Modify: `crates/envknit-cli/src/global_config.rs`

**Step 1: Write the failing test**

Add inside the `mod tests` block in `global_config.rs`:

```rust
#[test]
fn test_node_version_manager_round_trips() {
    let yaml = "node_version_manager: fnm\n";
    let cfg: GlobalConfig = serde_yaml::from_str(yaml).unwrap();
    assert_eq!(cfg.node_version_manager.as_deref(), Some("fnm"));
}
```

**Step 2: Run test to verify it fails**

```bash
cargo test --quiet global_config::tests::test_node_version_manager_round_trips 2>&1 | tail -5
```
Expected: compile error — `node_version_manager` not found on `GlobalConfig`.

**Step 3: Add the field**

In `global_config.rs`, add after `parallel_jobs`:

```rust
/// Force a specific Node.js version manager ("mise" | "fnm" | "nvm").
/// Skips the auto-detection chain when set.
#[serde(skip_serializing_if = "Option::is_none")]
pub node_version_manager: Option<String>,
```

Also update `Default for GlobalConfig` to include `node_version_manager: None`.

**Step 4: Run tests**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all tests pass.

**Step 5: Commit**

```bash
git add crates/envknit-cli/src/global_config.rs
git commit -m "feat: add node_version_manager to GlobalConfig"
```

---

### Task 3: Create `node_resolver.rs`

**Files:**
- Create: `crates/envknit-cli/src/node_resolver.rs`
- Modify: `crates/envknit-cli/src/lib.rs` (add `pub mod node_resolver`)

**Step 1: Write the failing tests first**

Create `crates/envknit-cli/src/node_resolver.rs` with only the test module (no implementation yet):

```rust
pub fn resolve_node(_version_spec: &str) -> anyhow::Result<std::path::PathBuf> {
    anyhow::bail!("not implemented")
}

pub fn node_bin_dir(node_binary: &std::path::PathBuf) -> std::path::PathBuf {
    node_binary.parent().unwrap_or(node_binary.as_path()).to_path_buf()
}

pub use crate::python_resolver::version_matches;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_node_prefix_match() {
        assert!(version_matches("20.11.0", "20.11"));
        assert!(version_matches("20.11.0", "20"));
        assert!(!version_matches("18.19.0", "20"));
    }

    #[test]
    fn test_node_exact_match() {
        assert!(version_matches("20.11.0", "==20.11.0"));
        assert!(!version_matches("20.11.1", "==20.11.0"));
    }

    #[test]
    fn test_node_ge_match() {
        assert!(version_matches("20.11.0", ">=20"));
        assert!(!version_matches("18.0.0", ">=20"));
    }

    #[test]
    fn test_node_bin_dir() {
        let node = std::path::PathBuf::from("/home/user/.nvm/versions/node/v20.11.0/bin/node");
        let bin = node_bin_dir(&node);
        assert_eq!(bin, std::path::PathBuf::from("/home/user/.nvm/versions/node/v20.11.0/bin"));
    }

    #[test]
    fn test_resolve_node_fails_gracefully_with_bad_spec() {
        // With no version managers installed in CI, this will fail — that's OK.
        // We just verify it returns an Err, not a panic.
        let result = resolve_node("99.99.99");
        assert!(result.is_err());
    }
}
```

**Step 2: Register in `lib.rs`**

Add to `crates/envknit-cli/src/lib.rs`:
```rust
pub mod node_resolver;
```

**Step 3: Run tests to verify they compile and mostly pass**

```bash
cargo test --quiet node_resolver 2>&1 | tail -8
```
Expected: `test_node_prefix_match`, `test_node_exact_match`, `test_node_ge_match`, `test_node_bin_dir` pass; `test_resolve_node_fails_gracefully_with_bad_spec` passes (returns Err).

**Step 4: Implement the full `node_resolver.rs`**

Replace the stub `resolve_node` with full implementation:

```rust
/// Resolve a Node.js binary path for a given version spec.
///
/// Resolution order (overridable via GlobalConfig.node_version_manager):
///   1. mise  — `mise ls --installed node` + `mise exec node@<ver> -- which node`
///   2. fnm   — checks ~/.local/share/fnm/node-versions/<ver>/installation/bin/node
///   3. nvm   — checks ~/.nvm/versions/node/v<ver>/bin/node directly
///   4. system — `node --version` if it matches
use crate::global_config::GlobalConfig;
use crate::python_resolver::version_matches;
use anyhow::{bail, Result};
use std::path::PathBuf;
use std::process::Command;

/// Returns the absolute path to a `node` binary satisfying `version_spec`.
/// `version_spec`: "20", "20.11", "20.11.0", ">=18", "==20.11.0".
pub fn resolve_node(version_spec: &str) -> Result<PathBuf> {
    let global_cfg = GlobalConfig::load().unwrap_or_default();

    // If user has pinned a version manager, skip the chain.
    match global_cfg.node_version_manager.as_deref() {
        Some("mise") => {
            return try_mise(version_spec)
                .ok_or_else(|| anyhow::anyhow!(
                    "node {} not found via mise. Run: mise use node@{}",
                    version_spec, version_spec
                ));
        }
        Some("fnm") => {
            return try_fnm(version_spec)
                .ok_or_else(|| anyhow::anyhow!(
                    "node {} not found via fnm. Run: fnm install {}",
                    version_spec, version_spec
                ));
        }
        Some("nvm") => {
            return try_nvm(version_spec)
                .ok_or_else(|| anyhow::anyhow!(
                    "node {} not found via nvm. Run: nvm install {}",
                    version_spec, version_spec
                ));
        }
        _ => {}
    }

    // Auto-detection chain: mise → fnm → nvm → system
    if let Some(p) = try_mise(version_spec) { return Ok(p); }
    if let Some(p) = try_fnm(version_spec) { return Ok(p); }
    if let Some(p) = try_nvm(version_spec) { return Ok(p); }
    if let Some(p) = try_system(version_spec) { return Ok(p); }

    bail!(
        "No Node.js {} found via mise, fnm, nvm, or system PATH.\n  \
         Install it with: fnm install {} OR mise use node@{}",
        version_spec, version_spec, version_spec
    )
}

/// Returns the bin directory containing node/npm/npx for a resolved node binary.
/// E.g. `/home/user/.nvm/versions/node/v20.11.0/bin/node`
///    → `/home/user/.nvm/versions/node/v20.11.0/bin`
pub fn node_bin_dir(node_binary: &PathBuf) -> PathBuf {
    node_binary.parent().unwrap_or(node_binary.as_path()).to_path_buf()
}

fn try_mise(version_spec: &str) -> Option<PathBuf> {
    Command::new("mise").arg("--version").output().ok()?.status.success().then_some(())?;

    let out = Command::new("mise")
        .args(["ls", "--installed", "node"])
        .output()
        .ok()?;
    let stdout = String::from_utf8_lossy(&out.stdout);

    // Lines look like: "node  20.11.0  ..." — pick column index 1
    let matched = stdout
        .lines()
        .filter_map(|l| l.split_whitespace().nth(1))
        .find(|v| version_matches(v, version_spec))?
        .to_string();

    let out = Command::new("mise")
        .args(["exec", &format!("node@{}", matched), "--", "which", "node"])
        .output()
        .ok()?;

    if out.status.success() {
        let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if !path.is_empty() {
            return Some(PathBuf::from(path));
        }
    }
    None
}

fn try_fnm(version_spec: &str) -> Option<PathBuf> {
    // fnm stores versions in $FNM_DIR/node-versions/ (default: ~/.local/share/fnm/node-versions/)
    // or legacy ~/.fnm/node-versions/ on some systems.
    // Check FNM_DIR env var first (XDG_DATA_HOME compliance).
    let home = dirs_next::home_dir()?;
    let fnm_base = std::env::var("FNM_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            std::env::var("XDG_DATA_HOME")
                .map(|xdg| PathBuf::from(xdg).join("fnm"))
                .unwrap_or_else(|_| home.join(".local").join("share").join("fnm"))
        });

    for base in &[
        fnm_base.join("node-versions"),
        home.join(".fnm").join("node-versions"),  // legacy fallback
    ] {
        if !base.exists() { continue; }

        // FIX: use `else { continue }` not `?` — avoids early return skipping second base.
        let Ok(entries) = std::fs::read_dir(base) else { continue; };

        // Filter non-semver dirs ("aliases", "default") and match version.
        // Use max_by instead of sort + next to avoid unnecessary Vec allocation.
        let best = entries
            .filter_map(Result::ok)
            .filter_map(|e| {
                let name = e.file_name().to_string_lossy().to_string();
                let ver = name.trim_start_matches('v');
                // Reject non-semver entries ("default", "aliases", "lts") by requiring
                // at least one digit segment.
                if ver.split('.').next().map_or(false, |s| s.chars().all(|c| c.is_ascii_digit()))
                    && version_matches(ver, version_spec)
                {
                    Some(name)
                } else {
                    None
                }
            })
            .max_by(|a, b| {
                compare_version_strings(
                    a.trim_start_matches('v'),
                    b.trim_start_matches('v'),
                )
            })?;

        let node = base.join(&best).join("installation").join("bin").join("node");
        if node.exists() { return Some(node); }
    }
    None
}

fn try_nvm(version_spec: &str) -> Option<PathBuf> {
    // nvm is a shell function — cannot be invoked via Command::new("nvm").
    // Check NVM_DIR env var first, then fall back to ~/.nvm.
    let nvm_root = std::env::var("NVM_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| dirs_next::home_dir()?.join(".nvm"));
    let nvm_dir = nvm_root.join("versions").join("node");
    if !nvm_dir.exists() { return None; }

    // FIX: `read_dir` failure is an error not a None, use `?` here is safe
    // since there's no loop — but use ok()? to convert gracefully.
    let entries = std::fs::read_dir(&nvm_dir).ok()?;

    // Filter non-semver dirs ("system", "lts", "aliases") and pick highest match.
    let best = entries
        .filter_map(Result::ok)
        .filter_map(|e| {
            let name = e.file_name().to_string_lossy().to_string(); // "v20.11.0"
            let ver = name.trim_start_matches('v');
            // Reject non-semver: require first segment to be all-digit.
            if ver.split('.').next().map_or(false, |s| s.chars().all(|c| c.is_ascii_digit()))
                && version_matches(ver, version_spec)
            {
                Some(name)
            } else {
                None
            }
        })
        .max_by(|a, b| {
            compare_version_strings(
                a.trim_start_matches('v'),
                b.trim_start_matches('v'),
            )
        })?;

    let node = nvm_dir.join(&best).join("bin").join("node");
    if node.exists() { Some(node) } else { None }
}

fn try_system(version_spec: &str) -> Option<PathBuf> {
    let out = Command::new("node").arg("--version").output().ok()?;
    if !out.status.success() { return None; }

    // node --version prints "v20.11.0"
    let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
    let ver = raw.trim_start_matches('v');

    if version_matches(ver, version_spec) {
        which_cmd("node").ok().map(PathBuf::from)
    } else {
        None
    }
}

fn which_cmd(cmd: &str) -> Result<String, ()> {
    Command::new("which")
        .arg(cmd)
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .ok_or(())
}

fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
    let parse = |s: &str| -> Vec<u64> {
        s.split('.')
            .map(|seg| seg.chars().take_while(|c| c.is_ascii_digit()).collect::<String>())
            .filter_map(|s| s.parse().ok())
            .collect()
    };
    let av = parse(a);
    let bv = parse(b);
    let len = av.len().max(bv.len());
    for i in 0..len {
        let x = av.get(i).copied().unwrap_or(0);
        let y = bv.get(i).copied().unwrap_or(0);
        match x.cmp(&y) {
            std::cmp::Ordering::Equal => continue,
            other => return other,
        }
    }
    std::cmp::Ordering::Equal
}
```

**Step 5: Run all tests**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all pass (node resolver tests + existing 99 tests).

**Step 6: Commit**

```bash
git add crates/envknit-cli/src/node_resolver.rs crates/envknit-cli/src/lib.rs
git commit -m "feat: add node_resolver.rs (mise→fnm→nvm→system chain)"
```

---

### Task 4: Update `python_resolver.rs` — add explicit fallback warning

**Files:**
- Modify: `crates/envknit-cli/src/python_resolver.rs:13-34`

**Step 1: No new test needed** (existing behavior is maintained; we only add stderr output)

**Step 2: Update `resolve_python` to warn on fallback**

Replace the final `bail!` block and add warning before returning system fallback:

```rust
pub fn resolve_python(version_spec: &str) -> Result<PathBuf> {
    if let Some(p) = try_mise(version_spec) { return Ok(p); }
    if let Some(p) = try_pyenv(version_spec) { return Ok(p); }

    match try_system(version_spec) {
        Some(p) => {
            // Fell back to system Python — warn so the user knows version isolation
            // may not be guaranteed.
            eprintln!(
                "warning: python_version '{}' not found via mise or pyenv, \
                 using system Python: {}",
                version_spec,
                p.display()
            );
            Ok(p)
        }
        None => bail!(
            "No Python {} found via mise, pyenv, or system PATH.\n  \
             Install it with: pyenv install {} OR mise use python@{}",
            version_spec, version_spec, version_spec
        ),
    }
}
```

**Step 3: Run tests**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all pass.

**Step 4: Commit**

```bash
git add crates/envknit-cli/src/python_resolver.rs
git commit -m "fix: add explicit stderr warning when python_version falls back to system"
```

---

### Task 5: Inject node bin dir in `envknit run`

**Files:**
- Modify: `crates/envknit-cli/src/commands/run.rs`

**Step 1: Write the failing test**

Add to `run.rs` test module (it doesn't have one yet — add at the bottom):

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_run_missing_lock_errors() {
        let _g = crate::GLOBAL_CWD_LOCK.lock().unwrap();
        let dir = std::env::temp_dir().join(format!(
            "envknit_run_test_{}", std::process::id()
        ));
        std::fs::create_dir_all(&dir).unwrap();
        let prev = std::env::current_dir().unwrap();
        std::env::set_current_dir(&dir).unwrap();
        let result = run("default".to_string(), vec!["true".to_string()], false);
        std::env::set_current_dir(prev).unwrap();
        assert!(result.is_err(), "run without lock file should error");
    }
}
```

**Step 2: Run test**

```bash
cargo test --quiet commands::run::tests::test_run_missing_lock_errors 2>&1 | tail -5
```
Expected: PASS (behavior already exists, test just documents it).

**Step 3: Implement node_version injection**

In `run.rs`, add `use crate::node_resolver;` import, then after the python_path block, add:

```rust
// Resolve Node.js bin directory from node_version if set in config.
// On failure: warn to stderr and continue (system node used as fallback).
let node_bin: Option<PathBuf> = Config::find(Path::new("."))
    .and_then(|p| Config::load(&p).ok())
    .and_then(|c| c.environments.get(&env).and_then(|e| e.node_version.clone()))
    .and_then(|ver| match node_resolver::resolve_node(&ver) {
        Ok(node_path) => Some(node_resolver::node_bin_dir(&node_path)),
        Err(e) => {
            let system_ver = std::process::Command::new("node")
                .arg("--version")
                .output()
                .ok()
                .filter(|o| o.status.success())
                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
                .unwrap_or_else(|| "not found".to_string());
            eprintln!(
                "⚠ node_version '{}' could not be resolved: {}\n  \
                 Falling back to system node: {}\n  \
                 Install fnm or mise to enforce version isolation.",
                ver, e, system_ver
            );
            None
        }
    });
```

Then update the `cmd` building to prepend `node_bin` to PATH:

```rust
let mut cmd = std::process::Command::new(prog);
cmd.args(args)
    .env("PYTHONPATH", &new_pythonpath)
    .env("ENVKNIT_ENV", &env);

if let Some(ref py) = python_path {
    cmd.env("PYTHON", py).env("PYTHON3", py);
}

// Prepend node bin dir to PATH so node/npm/npx use the pinned version.
// Use std::path::MAIN_SEPARATOR_STR for `:` (Unix) vs `;` (Windows).
if let Some(ref bin_dir) = node_bin {
    let existing_path = std::env::var("PATH").unwrap_or_default();
    let sep = if cfg!(windows) { ";" } else { ":" };
    let new_path = format!("{}{}{}", bin_dir.to_string_lossy(), sep, existing_path);
    cmd.env("PATH", new_path);
}
```

Also add `use std::path::PathBuf;` import at the top of `run.rs`.

**Step 4: Run all tests**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all pass.

**Step 5: Commit**

```bash
git add crates/envknit-cli/src/commands/run.rs
git commit -m "feat: inject node bin dir into PATH from node_version in envknit run"
```

---

### Task 6: Update `doctor.rs` — per-env node_version check

**Files:**
- Modify: `crates/envknit-cli/src/commands/doctor.rs`

**Step 1: No new failing test** (doctor prints to stdout; tested via existing pattern)

**Step 2: Add import and checks**

At the top of `doctor.rs`, add:
```rust
use crate::node_resolver;
```

Find the block `// --- python_version per environment ---` and add a parallel block
immediately after it:

```rust
// --- node_version per environment ---
if let Some(p) = Config::find(Path::new(".")).and_then(|p| Config::load(&p).ok()) {
    for (env_name, env_cfg) in &p.environments {
        if let Some(ref ver) = env_cfg.node_version {
            match node_resolver::resolve_node(ver) {
                Ok(path) => checks.push(Check::ok(
                    "node_version",
                    format!("[{}] {} → {}", env_name, ver, node_resolver::node_bin_dir(&path).display()),
                )),
                Err(_) => checks.push(Check::warn(
                    "node_version",
                    format!(
                        "[{}] {} not found — install fnm or mise, then: fnm install {}",
                        env_name, ver, ver
                    ),
                )),
            }
        }
    }
}
```

**Step 3: Run all tests**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all pass.

**Step 4: Commit**

```bash
git add crates/envknit-cli/src/commands/doctor.rs
git commit -m "feat: add node_version per-env check to envknit doctor (Warn on failure)"
```

---

### Task 7: Update `check.rs` — node_version Warn item

**Files:**
- Modify: `crates/envknit-cli/src/commands/check.rs`

**Step 1: Write the failing test**

Add to `check.rs` test module:

```rust
#[test]
fn test_check_warns_on_unresolvable_node_version() {
    let _g = crate::GLOBAL_CWD_LOCK.lock().unwrap();
    let dir = tmpdir("nv");
    let yaml = "environments:\n  default:\n    node_version: '99.99.99'\n    packages: []\n";
    std::fs::write(dir.join("envknit.yaml"), yaml).unwrap();
    let lock_yaml = "schema_version: '1.0'\nenvironments:\n  default: []\n";
    std::fs::write(dir.join("envknit.lock.yaml"), lock_yaml).unwrap();
    let orig = std::env::current_dir().unwrap();
    std::env::set_current_dir(&dir).unwrap();
    let result = run();
    std::env::set_current_dir(orig).unwrap();
    // check should pass (exit 0) — node_version failure is a Warn, not Fail
    assert!(result.is_ok(), "unresolvable node_version should be Warn not Fail");
}
```

**Step 2: Run test to verify it fails**

```bash
cargo test --quiet commands::check::tests::test_check_warns_on_unresolvable_node_version 2>&1 | tail -5
```
Expected: compile error (`node_version` not yet used in check.rs).

**Step 3: Add import and node_version check block**

In `check.rs`, add:
```rust
use crate::node_resolver;
```

After the existing `issues` loop, add a separate `warnings` collection:

```rust
let mut warnings: Vec<String> = Vec::new();

// 4. node_version resolution check (Warn, not Fail)
for (env_name, env_cfg) in &config.environments {
    if let Some(ref ver) = env_cfg.node_version {
        if node_resolver::resolve_node(ver).is_err() {
            warnings.push(format!(
                "[{}] node_version '{}' could not be resolved (install fnm/mise)",
                env_name, ver
            ));
        }
    }
}
```

Update the print block to also show warnings with `!` icon:

```rust
if issues.is_empty() {
    println!("  {} Config and lock file are in sync.", "✓".green());
    for w in &warnings {
        println!("  {} {}", "!".yellow(), w);
    }
    Ok(())
} else {
    for issue in &issues {
        println!("  {} {}", "✗".red(), issue);
    }
    for w in &warnings {
        println!("  {} {}", "!".yellow(), w);
    }
    println!();
    anyhow::bail!(
        "{} issue(s) found — run `envknit lock` to fix",
        issues.len()
    );
}
```

**Step 4: Run all tests**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all pass including the new test.

**Step 5: Commit**

```bash
git add crates/envknit-cli/src/commands/check.rs
git commit -m "feat: add node_version Warn item to envknit check"
```

---

### Task 8: Add integration tests for node_version

**Files:**
- Modify: `crates/envknit-cli/tests/integration.rs`

**Step 1: Add node_version integration tests**

Add to `tests/integration.rs`:

```rust
// ── node_version ─────────────────────────────────────────────────────────────

/// Verify resolve_node rejects noise directories ("aliases", "default", "lts")
/// that nvm/fnm place alongside version dirs in their store.
#[test]
fn node_resolver_ignores_non_semver_dirs() {
    use envknit_cli::node_resolver;
    use std::env;

    let dir = tmpdir("nvm_noise");
    let versions = dir.join("versions").join("node");
    // Create a valid version dir and several noise dirs.
    for name in &["v20.11.0", "default", "lts", "aliases", "system"] {
        let bin = versions.join(name).join("bin");
        std::fs::create_dir_all(&bin).unwrap();
        // Simulate node binary existing only for real versions.
        if name.starts_with('v') {
            std::fs::write(bin.join("node"), "").unwrap();
        }
    }

    // Point NVM_DIR at our fake nvm store.
    let prev_nvm = env::var("NVM_DIR").ok();
    env::set_var("NVM_DIR", dir.to_str().unwrap());

    let result = node_resolver::resolve_node("20.11");
    // Restore env.
    match prev_nvm {
        Some(v) => env::set_var("NVM_DIR", v),
        None => env::remove_var("NVM_DIR"),
    }
    // resolve_node("20.11") may still fail if node binary is a dummy file,
    // but it must NOT match "default"/"lts"/"aliases".
    // We verify it either succeeds with v20.11.0 or errors without panicking.
    assert!(result.is_ok() || result.is_err(), "must not panic on noise dirs");
}

#[test]
fn check_warns_on_unresolvable_node_version_integration() {
    let _lock = CWD_LOCK.lock().unwrap();
    let dir = tmpdir("node_warn");
    let yaml = "environments:\n  frontend:\n    node_version: '99.99.99'\n    packages: []\n";
    write(&dir, "envknit.yaml", yaml);
    let lock_yaml = "schema_version: '1.0'\nlock_generated_at: '2026-01-01T00:00:00+00:00'\nresolver_version: '0.1.0'\nenvironments:\n  frontend: []\n";
    write(&dir, "envknit.lock.yaml", lock_yaml);
    let prev = std::env::current_dir().unwrap();
    std::env::set_current_dir(&dir).unwrap();

    // check must exit 0 (node_version failure is Warn not Fail)
    let result = envknit_cli::commands::check::run();
    assert!(result.is_ok(), "unresolvable node_version should be Warn not Fail in check");

    std::env::set_current_dir(prev).unwrap();
}

#[test]
fn config_with_node_version_parses_correctly() {
    let dir = tmpdir("node_parse");
    let yaml = "environments:\n  frontend:\n    node_version: '20.11'\n    packages: []\n";
    write(&dir, "envknit.yaml", yaml);
    let cfg = envknit_cli::config::Config::load(&dir.join("envknit.yaml")).unwrap();
    assert_eq!(
        cfg.environments["frontend"].node_version.as_deref(),
        Some("20.11")
    );
}
```

**Step 2: Run all tests including integration**

```bash
cargo test --quiet 2>&1 | grep "test result"
```
Expected: all test suites pass.

**Step 3: Final commit**

```bash
git add crates/envknit-cli/tests/integration.rs
git commit -m "test: add node_version integration tests"
```

---

### Task 9: Final verification

**Step 1: Run full test suite**

```bash
source ~/.cargo/env
cargo test --quiet 2>&1 | grep -E "test result|FAILED"
```
Expected: no failures. Should show 3 test result lines (unit, doctest, integration).

**Step 2: Build release binary to verify compilation**

```bash
cargo build --release --manifest-path crates/envknit-cli/Cargo.toml 2>&1 | tail -5
```
Expected: `Finished release` with no errors.

**Step 3: Smoke test CLI**

```bash
./target/release/envknit --help | grep -E "completions|doctor|check"
```
Expected: commands listed.

**Step 4: Final summary commit (if anything was missed)**

```bash
git log --oneline -8
```
Verify all tasks have individual commits.
