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