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

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