From eeaa65f7ce7ea6ff2e96c3bccec808bc3bb72a91 Mon Sep 17 00:00:00 2001 From: payneio Date: Sun, 5 Jul 2026 19:17:23 -0700 Subject: [PATCH] feat: resolve a program's pinned node version for build + runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/.node-version | 1 + core/src/castle_core/config.py | 5 +- core/src/castle_core/deploy.py | 15 +++ core/src/castle_core/generators/systemd.py | 14 ++- core/src/castle_core/registry.py | 7 ++ core/src/castle_core/stacks.py | 32 +++-- core/src/castle_core/toolchains.py | 133 +++++++++++++++++++++ core/tests/test_systemd.py | 52 ++++++++ core/tests/test_toolchains.py | 121 +++++++++++++++++++ docs/stacks/react-vite.md | 23 ++++ 10 files changed, 393 insertions(+), 10 deletions(-) create mode 100644 app/.node-version create mode 100644 core/src/castle_core/toolchains.py create mode 100644 core/tests/test_toolchains.py diff --git a/app/.node-version b/app/.node-version new file mode 100644 index 0000000..8e35034 --- /dev/null +++ b/app/.node-version @@ -0,0 +1 @@ +24.14.1 diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 5f316af..1306ccb 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -75,7 +75,10 @@ REPOS_DIR = _resolve_repos_dir() # Order matters: pnpm's modern standalone installer puts its shim in # $PNPM_HOME/bin, which must win over the bare dir (older installs leave a stale # version wrapper there). nvm/node is intentionally omitted — it's versioned and -# brittle; a service needing a specific node should pin it via defaults.env. +# brittle to hardcode. A program's node is resolved per-program from its declared +# pin (.node-version/.nvmrc/engines) and prepended ahead of these dirs at both the +# build subprocess (stacks._build_env) and the runtime unit PATH (Deployment +# .path_prepend) — see castle_core.toolchains. USER_TOOL_PATH_DIRS = [ Path.home() / ".local" / "bin", Path.home() / ".local" / "share" / "pnpm" / "bin", diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 5f31233..bcd147e 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -59,6 +59,7 @@ from castle_core.registry import ( load_registry, save_registry, ) +from castle_core.toolchains import ToolchainError, resolve_node_bin SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" @@ -695,12 +696,26 @@ def _build_deployed( ) stop_cmd = _build_stop_cmd(name, run, source_dir) + # A program that pins a node version (.node-version/.nvmrc/engines) → that node's + # bin dir on the unit PATH, so a `launcher: node` service runs the program's node + # (the default tool PATH omits nvm's versioned dirs). Harmless for non-node + # programs (no pin → no prepend). Fail-soft: a missing pinned version is a warning, + # not an aborted apply — it surfaces again, loudly, when the build/verb runs. + path_prepend: list[str] = [] + try: + node_bin = resolve_node_bin(source_dir) + if node_bin is not None: + path_prepend = [str(node_bin)] + except ToolchainError as e: + messages.append(f"⚠ {name}: {e}") + return Deployment( manager="systemd", launcher=run.launcher, run_cmd=run_cmd, stop_cmd=stop_cmd, env=env, + path_prepend=path_prepend, secret_env_keys=sorted(secret_env), description=description, kind=kind, diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index 93c0a7a..f6aca8f 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -112,8 +112,18 @@ def generate_unit_from_deployed( env_lines = "" for key, value in deployed.env.items(): env_lines += f"Environment={key}={value}\n" - tool_path = ":".join(str(d) for d in USER_TOOL_PATH_DIRS if d.exists()) - env_lines += f'Environment="PATH={tool_path}:/usr/local/bin:/usr/bin:/bin"\n' + # Castle supplies a sensible default PATH (tool dirs + system bins). It is an + # escape hatch, not a mandate: if the service pins its own PATH in defaults.env + # (e.g. to add a versioned nvm node the tool dirs intentionally omit), respect + # it rather than clobbering it with a trailing Environment=PATH line that would + # win under systemd's last-assignment-wins rule. systemd does NOT expand + # ${PATH} across Environment= lines, so a service that overrides PATH must + # spell out the full value, tool dirs included. + if "PATH" not in deployed.env: + dirs = list(deployed.path_prepend) # resolved toolchain (e.g. pinned node bin) + dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()] + dirs += ["/usr/local/bin", "/usr/bin", "/bin"] + env_lines += f'Environment="PATH={":".join(dirs)}"\n' if env_file is not None: env_lines += f"EnvironmentFile={env_file}\n" diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 86d1ed4..b87f927 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -52,6 +52,10 @@ class Deployment: # ``down``). Empty for launchers whose stop is just SIGTERM to the ExecStart pid. stop_cmd: list[str] = field(default_factory=list) env: dict[str, str] = field(default_factory=dict) + # Absolute dirs prepended to the unit's default PATH — a resolved toolchain the + # default tool PATH omits (e.g. a program's pinned nvm node bin). Ignored when + # the deployment sets its own PATH in env (an explicit PATH is a full override). + path_prepend: list[str] = field(default_factory=list) # Names (never values) of secret-bearing env vars. Their resolved values live # only in the mode-0600 env file, never in env/run_cmd/registry — this is for # visibility (which secrets a deployment expects). @@ -152,6 +156,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry: run_cmd=comp_data.get("run_cmd", []), stop_cmd=comp_data.get("stop_cmd", []), env=comp_data.get("env", {}), + path_prepend=comp_data.get("path_prepend", []), secret_env_keys=comp_data.get("secret_env_keys", []), description=comp_data.get("description"), kind=kind, @@ -215,6 +220,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: entry["stop_cmd"] = comp.stop_cmd if comp.env: entry["env"] = comp.env + if comp.path_prepend: + entry["path_prepend"] = comp.path_prepend if comp.secret_env_keys: entry["secret_env_keys"] = comp.secret_env_keys if comp.description: diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 331f3de..574eb64 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -11,6 +11,7 @@ from pathlib import Path from castle_core.config import USER_TOOL_PATH_DIRS from castle_core.manifest import ProgramSpec +from castle_core.toolchains import ToolchainError, resolve_node_bin DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"] INSTALL_ACTIONS = ["install", "uninstall"] @@ -41,20 +42,37 @@ class ActionResult: output: str = "" -def _build_env() -> dict[str, str]: - """Build a subprocess env with user tool dirs on PATH.""" +def _build_env(node_source: Path | None = None) -> dict[str, str]: + """Build a subprocess env with user tool dirs on PATH. + + ``node_source`` is the program's source dir: if it pins a node version (see + :mod:`castle_core.toolchains`), that node's bin dir goes on the front of PATH so + the verb uses the program's node instead of whatever ambient node the caller + happens to have (the CLI inherits your shell's; the castle-api build executor's + default PATH has none). Raises :class:`ToolchainError` if the pin isn't installed. + """ env = os.environ.copy() - extra = ":".join(str(d) for d in USER_TOOL_PATH_DIRS if d.exists()) - if extra: - env["PATH"] = extra + ":" + env.get("PATH", "") + dirs = [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()] + node_bin = resolve_node_bin(node_source) + if node_bin is not None: + dirs.insert(0, str(node_bin)) + if dirs: + env["PATH"] = ":".join(dirs) + ":" + env.get("PATH", "") return env async def _run( cmd: list[str], cwd: Path, env: dict[str, str] | None = None ) -> tuple[int, str]: - """Run a subprocess and return (returncode, combined output).""" - run_env = _build_env() + """Run a subprocess and return (returncode, combined output). + + The verb runs in ``cwd`` (the program source), so that dir doubles as the node + pin source — a pinned-but-missing node fails loud here rather than as a cryptic + ``node: not found`` mid-build.""" + try: + run_env = _build_env(cwd) + except ToolchainError as e: + return 1, str(e) if env: run_env.update(env) proc = await asyncio.create_subprocess_exec( diff --git a/core/src/castle_core/toolchains.py b/core/src/castle_core/toolchains.py new file mode 100644 index 0000000..06ffdca --- /dev/null +++ b/core/src/castle_core/toolchains.py @@ -0,0 +1,133 @@ +"""Toolchain resolution — map a program's declared runtime version to a concrete +binary directory on this box. + +Today this covers **node**. Programs pin their node version the ecosystem-standard +way — a ``.node-version`` / ``.nvmrc`` file, or ``package.json`` ``engines.node`` / +``volta.node`` — so a program stays castle-independent (the prime directive: regular +programs never depend on castle). Castle *reads* that pin and resolves it to an +absolute ``.../bin`` directory it can put on PATH, at both execution sites that run a +program's node: + +- **build time** — the dev-verb subprocess (``stacks._build_env``), so ``castle + program build`` uses the program's node regardless of who triggers it (your shell + or the castle-api build executor); +- **run time** — a ``launcher: node`` service's systemd unit PATH (via + ``deploy._build_deployed`` → the generated unit's ``Environment=PATH``). + +Resolution scans nvm's versioned install layout (``CASTLE_NODE_VERSIONS_DIR``, +default ``~/.nvm/versions/node``) — the versioned dir the default tool PATH +intentionally omits. A pinned-but-not-installed version fails loud with an +actionable ``nvm install`` hint rather than surfacing later as ``node: not found``. +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + +# A pin that is a bare, exact-ish version: "24", "24.14", "24.14.1" (optional "v"). +_EXACT_RE = re.compile(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$") +# An installed nvm dir: v24.14.1 +_INSTALLED_RE = re.compile(r"v(\d+)\.(\d+)\.(\d+)$") +# Wildcards/aliases that mean "newest installed". +_NEWEST_ALIASES = {"", "*", "x", "node", "latest", "current", "lts", "lts/*"} + + +class ToolchainError(Exception): + """A program pins a toolchain version that isn't installed on this box.""" + + +def node_versions_dir() -> Path: + """The directory nvm installs versioned node under (env-overridable).""" + override = os.environ.get("CASTLE_NODE_VERSIONS_DIR") + return Path(override) if override else Path.home() / ".nvm" / "versions" / "node" + + +def read_node_pin(source: Path) -> str | None: + """The node version a program pins, read the ecosystem-standard way. + + Precedence: ``.node-version`` → ``.nvmrc`` → ``package.json`` (``engines.node``, + else ``volta.node``). Returns the raw spec string, or ``None`` if unpinned.""" + for fname in (".node-version", ".nvmrc"): + f = source / fname + if f.is_file(): + val = f.read_text().strip() + if val: + return val + pkg = source / "package.json" + if pkg.is_file(): + try: + data = json.loads(pkg.read_text()) + except (json.JSONDecodeError, OSError): + return None + for section in ("engines", "volta"): + node = (data.get(section) or {}).get("node") + if isinstance(node, str) and node.strip(): + return node.strip() + return None + + +def _installed() -> list[tuple[tuple[int, int, int], Path]]: + """Installed (version, bin-dir) pairs, newest first.""" + root = node_versions_dir() + out: list[tuple[tuple[int, int, int], Path]] = [] + if not root.is_dir(): + return out + for child in root.iterdir(): + m = _INSTALLED_RE.match(child.name) + if m and (child / "bin" / "node").exists(): + out.append(((int(m[1]), int(m[2]), int(m[3])), child / "bin")) + out.sort(reverse=True) + return out + + +def resolve_node_bin(source: Path | None) -> Path | None: + """Resolve a program's pinned node version to its ``.../bin`` dir on this box. + + Returns ``None`` when the program pins nothing — castle injects no node, it does + not guess. Raises :class:`ToolchainError` when a version *is* pinned but no + matching version is installed. + + Matching: an exact-ish pin (``24`` / ``24.14`` / ``24.14.1``) matches installed + versions by that precision, newest wins. A range or alias (``>=24``, ``^24.1``, + ``lts/*``) is pinned by its leading major (or newest installed if it names none) + — precise pins belong in ``.node-version``.""" + if source is None: + return None + pin = read_node_pin(source) + if not pin: + return None + installed = _installed() + + def newest_or_raise() -> Path: + if installed: + return installed[0][1] + raise ToolchainError( + f"node {pin!r} pinned (in {source}) but no node is installed under " + f"{node_versions_dir()} — run `nvm install {pin}`" + ) + + low = pin.strip().lower() + if low in _NEWEST_ALIASES: + return newest_or_raise() + + exact = _EXACT_RE.match(low) + if exact: + parts = [int(g) for g in exact.groups() if g is not None] + else: + # A range/alias (>=24, ^24.1, ~24, 24.x): pin by leading major only. + major = re.search(r"\d+", low) + if not major: + return newest_or_raise() + parts = [int(major.group())] + precision = len(parts) + + for ver, bindir in installed: # newest first + if list(ver[:precision]) == parts: + return bindir + raise ToolchainError( + f"node {pin!r} pinned (in {source}) but not installed under " + f"{node_versions_dir()} — run `nvm install {pin}`" + ) diff --git a/core/tests/test_systemd.py b/core/tests/test_systemd.py index 82e1b32..d1e35d7 100644 --- a/core/tests/test_systemd.py +++ b/core/tests/test_systemd.py @@ -59,6 +59,58 @@ class TestUnitFromDeployed: unit = generate_unit_from_deployed("test-svc", deployed) assert "Environment=TEST_SVC_DATA_DIR=/home/user/.castle/data/test-svc" in unit + def test_default_path_emitted_when_absent(self) -> None: + """Castle supplies a default PATH when the service doesn't pin one.""" + deployed = Deployment( + manager="systemd", launcher="python", + run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], + env={}, + description="Test service", + ) + unit = generate_unit_from_deployed("test-svc", deployed) + assert 'Environment="PATH=' in unit + assert "/usr/local/bin:/usr/bin:/bin" in unit + + def test_path_prepend_precedes_default(self) -> None: + """A resolved toolchain dir (e.g. pinned node bin) leads the default PATH.""" + deployed = Deployment( + manager="systemd", launcher="node", + run_cmd=["node", "server.js"], + env={}, + path_prepend=["/home/user/.nvm/versions/node/v24.14.1/bin"], + description="Node service", + ) + unit = generate_unit_from_deployed("node-svc", deployed) + path_line = next(ln for ln in unit.splitlines() if "PATH=" in ln) + assert "/home/user/.nvm/versions/node/v24.14.1/bin:" in path_line + assert path_line.index("v24.14.1") < path_line.index("/usr/bin") + + def test_explicit_path_overrides_path_prepend(self) -> None: + """An explicit PATH is a full override — path_prepend is ignored too.""" + deployed = Deployment( + manager="systemd", launcher="node", + run_cmd=["node", "server.js"], + env={"PATH": "/opt/node/bin:/usr/bin:/bin"}, + path_prepend=["/home/user/.nvm/versions/node/v24.14.1/bin"], + description="Node service", + ) + unit = generate_unit_from_deployed("node-svc", deployed) + assert "v24.14.1" not in unit + assert unit.count("PATH=") == 1 + + def test_explicit_path_overrides_default(self) -> None: + """A PATH pinned in defaults.env wins — Castle does not append its own, + which would clobber it under systemd's last-assignment-wins rule.""" + deployed = Deployment( + manager="systemd", launcher="python", + run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], + env={"PATH": "/opt/node/bin:/usr/bin:/bin"}, + description="Test service", + ) + unit = generate_unit_from_deployed("test-svc", deployed) + assert "Environment=PATH=/opt/node/bin:/usr/bin:/bin" in unit + assert unit.count("PATH=") == 1 # exactly one PATH line, the explicit one + def test_contains_restart_policy(self) -> None: """Unit file has restart configuration.""" deployed = Deployment( diff --git a/core/tests/test_toolchains.py b/core/tests/test_toolchains.py new file mode 100644 index 0000000..2bd20e7 --- /dev/null +++ b/core/tests/test_toolchains.py @@ -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: /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" diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index a8e7c7d..65228e7 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -121,6 +121,29 @@ pnpm run check # lint + type-check + test The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files. +## Pinning the node version + +Commit a **`.node-version`** (or `.nvmrc`, or `package.json` `engines.node`) at the +project root: + +``` +24.14.1 +``` + +Castle reads this pin and puts the matching node on PATH whenever it runs the +program's node — at build time (`castle program build`, incl. builds triggered from +the castle web app, which run inside the `castle-api` service) and at run time (a +`launcher: node` service's systemd unit). This is why a build works from the web app +even though the service's default PATH deliberately omits nvm's versioned dirs. + +The pin is standard, tool-agnostic config — the program stays castle-independent; nvm +(and editors/CI) honor the same file. Castle resolves it against +`~/.nvm/versions/node` (override with `CASTLE_NODE_VERSIONS_DIR`), newest match wins. +An exact pin (`24.14.1`) matches exactly; a partial (`24`) or a range (`>=24`) matches +the newest installed major. If the pinned version isn't installed, the verb fails loud +with a `nvm install ` hint instead of a cryptic `node: not found`. Unpinned → +Castle injects no node (it uses whatever ambient node is on PATH, and does not guess). + ## Registering as a program A frontend program has a `build` spec (produces static output). Register it in