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

@@ -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",

View File

@@ -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,

View File

@@ -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"

View File

@@ -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:

View File

@@ -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(

View File

@@ -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}`"
)

View File

@@ -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(

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"