feat: resolve a program's pinned node version for build + runtime

Frontend builds triggered from the castle web app run inside the castle-api
systemd service, whose PATH omits nvm's versioned node dir — so `pnpm build`
died with `node: not found` even though it worked from an interactive shell.

Introduce a per-program node convention: a program declares its version the
ecosystem-standard way (.node-version / .nvmrc / package.json engines.node),
and castle_core.toolchains.resolve_node_bin() maps it to a concrete nvm bin dir
(CASTLE_NODE_VERSIONS_DIR, default ~/.nvm/versions/node; newest match wins).
The same resolver feeds both sites that run a program's node:

- build time: stacks._build_env() prepends the pinned node for the dev-verb
  subprocess (keyed on the source dir), so `castle program build` uses the
  program's node regardless of caller.
- run time: deploy._build_deployed() stores it in Deployment.path_prepend,
  which the systemd generator puts ahead of the default unit PATH — so a
  `launcher: node` service runs its program's node.

A pinned-but-uninstalled version fails loud with an `nvm install` hint instead
of a cryptic `node: not found`. Unpinned → no injection (no guessing).

Also: the systemd generator now honors an explicit PATH in defaults.env as a
full override instead of clobbering it with a trailing Environment=PATH line
(systemd's last-assignment-wins rule had silently defeated the documented
escape hatch).

Pins app/.node-version and documents the convention in the react-vite stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 19:17:23 -07:00
parent f641e7f8d3
commit eeaa65f7ce
10 changed files with 393 additions and 10 deletions

View File

@@ -0,0 +1,121 @@
"""Tests for toolchain (node) version resolution."""
from __future__ import annotations
from pathlib import Path
import pytest
from castle_core import toolchains
from castle_core.toolchains import (
ToolchainError,
read_node_pin,
resolve_node_bin,
)
def _install(root: Path, *versions: str) -> None:
"""Create fake nvm-style installs: <root>/vX.Y.Z/bin/node."""
for v in versions:
bindir = root / v / "bin"
bindir.mkdir(parents=True)
(bindir / "node").write_text("#!/bin/sh\n")
@pytest.fixture
def nvm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
root = tmp_path / "nvm"
root.mkdir()
monkeypatch.setenv("CASTLE_NODE_VERSIONS_DIR", str(root))
return root
class TestReadNodePin:
def test_node_version_file(self, tmp_path: Path) -> None:
(tmp_path / ".node-version").write_text("24.14.1\n")
assert read_node_pin(tmp_path) == "24.14.1"
def test_nvmrc_when_no_node_version(self, tmp_path: Path) -> None:
(tmp_path / ".nvmrc").write_text("20\n")
assert read_node_pin(tmp_path) == "20"
def test_node_version_wins_over_nvmrc(self, tmp_path: Path) -> None:
(tmp_path / ".node-version").write_text("24\n")
(tmp_path / ".nvmrc").write_text("20\n")
assert read_node_pin(tmp_path) == "24"
def test_package_json_engines(self, tmp_path: Path) -> None:
(tmp_path / "package.json").write_text('{"engines": {"node": ">=22"}}')
assert read_node_pin(tmp_path) == ">=22"
def test_package_json_volta_fallback(self, tmp_path: Path) -> None:
(tmp_path / "package.json").write_text('{"volta": {"node": "18.20.0"}}')
assert read_node_pin(tmp_path) == "18.20.0"
def test_unpinned(self, tmp_path: Path) -> None:
assert read_node_pin(tmp_path) is None
def test_malformed_package_json(self, tmp_path: Path) -> None:
(tmp_path / "package.json").write_text("{not json")
assert read_node_pin(tmp_path) is None
class TestResolveNodeBin:
def test_unpinned_returns_none(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1")
assert resolve_node_bin(tmp_path) is None
def test_none_source(self) -> None:
assert resolve_node_bin(None) is None
def test_exact_match(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v20.10.0")
(tmp_path / ".node-version").write_text("24.14.1")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_major_prefix_picks_newest(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.1.0", "v24.14.1", "v24.9.0", "v20.10.0")
(tmp_path / ".node-version").write_text("24")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_major_minor_prefix(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v24.14.9", "v24.9.0")
(tmp_path / ".node-version").write_text("24.14")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.9" / "bin"
def test_engines_range_matches_major(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v20.10.0")
(tmp_path / "package.json").write_text('{"engines": {"node": ">=24"}}')
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_caret_range_matches_major(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.1.0", "v24.14.1")
(tmp_path / "package.json").write_text('{"engines": {"node": "^24.1.0"}}')
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_alias_lts_picks_newest(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v20.10.0")
(tmp_path / ".nvmrc").write_text("lts/*")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_pinned_but_not_installed_raises(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v20.10.0")
(tmp_path / ".node-version").write_text("24")
with pytest.raises(ToolchainError, match="nvm install"):
resolve_node_bin(tmp_path)
def test_no_installs_raises_for_alias(self, tmp_path: Path, nvm: Path) -> None:
(tmp_path / ".nvmrc").write_text("node")
with pytest.raises(ToolchainError):
resolve_node_bin(tmp_path)
def test_missing_bin_not_counted(self, tmp_path: Path, nvm: Path) -> None:
# A version dir with no bin/node is not a usable install.
(nvm / "v24.14.1").mkdir(parents=True)
(tmp_path / ".node-version").write_text("24")
with pytest.raises(ToolchainError):
resolve_node_bin(tmp_path)
def test_default_dir_is_nvm(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CASTLE_NODE_VERSIONS_DIR", raising=False)
assert toolchains.node_versions_dir() == Path.home() / ".nvm" / "versions" / "node"