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:
@@ -59,6 +59,58 @@ class TestUnitFromDeployed:
|
||||
unit = generate_unit_from_deployed("test-svc", deployed)
|
||||
assert "Environment=TEST_SVC_DATA_DIR=/home/user/.castle/data/test-svc" in unit
|
||||
|
||||
def test_default_path_emitted_when_absent(self) -> None:
|
||||
"""Castle supplies a default PATH when the service doesn't pin one."""
|
||||
deployed = Deployment(
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={},
|
||||
description="Test service",
|
||||
)
|
||||
unit = generate_unit_from_deployed("test-svc", deployed)
|
||||
assert 'Environment="PATH=' in unit
|
||||
assert "/usr/local/bin:/usr/bin:/bin" in unit
|
||||
|
||||
def test_path_prepend_precedes_default(self) -> None:
|
||||
"""A resolved toolchain dir (e.g. pinned node bin) leads the default PATH."""
|
||||
deployed = Deployment(
|
||||
manager="systemd", launcher="node",
|
||||
run_cmd=["node", "server.js"],
|
||||
env={},
|
||||
path_prepend=["/home/user/.nvm/versions/node/v24.14.1/bin"],
|
||||
description="Node service",
|
||||
)
|
||||
unit = generate_unit_from_deployed("node-svc", deployed)
|
||||
path_line = next(ln for ln in unit.splitlines() if "PATH=" in ln)
|
||||
assert "/home/user/.nvm/versions/node/v24.14.1/bin:" in path_line
|
||||
assert path_line.index("v24.14.1") < path_line.index("/usr/bin")
|
||||
|
||||
def test_explicit_path_overrides_path_prepend(self) -> None:
|
||||
"""An explicit PATH is a full override — path_prepend is ignored too."""
|
||||
deployed = Deployment(
|
||||
manager="systemd", launcher="node",
|
||||
run_cmd=["node", "server.js"],
|
||||
env={"PATH": "/opt/node/bin:/usr/bin:/bin"},
|
||||
path_prepend=["/home/user/.nvm/versions/node/v24.14.1/bin"],
|
||||
description="Node service",
|
||||
)
|
||||
unit = generate_unit_from_deployed("node-svc", deployed)
|
||||
assert "v24.14.1" not in unit
|
||||
assert unit.count("PATH=") == 1
|
||||
|
||||
def test_explicit_path_overrides_default(self) -> None:
|
||||
"""A PATH pinned in defaults.env wins — Castle does not append its own,
|
||||
which would clobber it under systemd's last-assignment-wins rule."""
|
||||
deployed = Deployment(
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={"PATH": "/opt/node/bin:/usr/bin:/bin"},
|
||||
description="Test service",
|
||||
)
|
||||
unit = generate_unit_from_deployed("test-svc", deployed)
|
||||
assert "Environment=PATH=/opt/node/bin:/usr/bin:/bin" in unit
|
||||
assert unit.count("PATH=") == 1 # exactly one PATH line, the explicit one
|
||||
|
||||
def test_contains_restart_policy(self) -> None:
|
||||
"""Unit file has restart configuration."""
|
||||
deployed = Deployment(
|
||||
|
||||
Reference in New Issue
Block a user