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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
133
core/src/castle_core/toolchains.py
Normal file
133
core/src/castle_core/toolchains.py
Normal 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}`"
|
||||
)
|
||||
Reference in New Issue
Block a user