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

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