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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user