fix(core): unify build-time and runtime PATH via USER_TOOL_PATH_DIRS

Generated systemd units hardcoded a thin PATH (~/.local/bin + system dirs)
while dev-verb builds used a separate _EXTRA_PATH_DIRS list, so a service
could be built and run with different tools on PATH. Hoist a single
USER_TOOL_PATH_DIRS in config.py, consumed by both stacks._build_env
(build) and the systemd unit generator (runtime).

Also prefer pnpm's modern $PNPM_HOME/bin shim over the bare $PNPM_HOME dir
(older installs leave a stale version wrapper there) and add
/usr/local/go/bin so Go-based builds/services resolve their toolchain.
This commit is contained in:
2026-06-16 17:47:49 -07:00
parent 414168dc43
commit 290bcb6383
3 changed files with 133 additions and 47 deletions

View File

@@ -75,6 +75,20 @@ DATA_DIR = _resolve_data_dir()
SECRETS_DIR = CASTLE_HOME / "secrets" SECRETS_DIR = CASTLE_HOME / "secrets"
REPOS_DIR = _resolve_repos_dir() REPOS_DIR = _resolve_repos_dir()
# User tool directories — the single source of truth for "where our CLIs live".
# Used both at build time (dev-verb subprocess PATH) and at run time (generated
# systemd unit PATH) so a service sees the same tools castle used to build it.
# 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.
USER_TOOL_PATH_DIRS = [
Path.home() / ".local" / "bin",
Path.home() / ".local" / "share" / "pnpm" / "bin",
Path.home() / ".local" / "share" / "pnpm",
Path("/usr/local/go/bin"),
]
# Backwards-compat aliases (used by existing imports) # Backwards-compat aliases (used by existing imports)
GENERATED_DIR = SPECS_DIR GENERATED_DIR = SPECS_DIR
STATIC_DIR = CONTENT_DIR STATIC_DIR = CONTENT_DIR
@@ -97,8 +111,7 @@ def find_castle_root() -> Path:
return current return current
current = current.parent current = current.parent
raise FileNotFoundError( raise FileNotFoundError(
"Could not find castle.yaml.\n" f"Could not find castle.yaml.\nExpected at: {CASTLE_HOME / 'castle.yaml'}"
f"Expected at: {CASTLE_HOME / 'castle.yaml'}"
) )
@@ -125,11 +138,7 @@ class CastleConfig:
@property @property
def tools(self) -> dict[str, ProgramSpec]: def tools(self) -> dict[str, ProgramSpec]:
"""Return programs that are tools (behavior == 'tool').""" """Return programs that are tools (behavior == 'tool')."""
return { return {k: v for k, v in self.programs.items() if v.behavior == "tool"}
k: v
for k, v in self.programs.items()
if v.behavior == "tool"
}
@property @property
def frontends(self) -> dict[str, ProgramSpec]: def frontends(self) -> dict[str, ProgramSpec]:
@@ -230,8 +239,7 @@ def _expand_units(
for name, unit in units.items(): for name, unit in units.items():
if name in programs or name in services or name in jobs: if name in programs or name in services or name in jobs:
raise ValueError( raise ValueError(
f"Unit '{name}' conflicts with existing entry in " f"Unit '{name}' conflicts with existing entry in programs/services/jobs"
f"programs/services/jobs"
) )
defaults = _STACK_DEFAULTS.get(unit.stack or "", {}) defaults = _STACK_DEFAULTS.get(unit.stack or "", {})

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import shutil import shutil
from pathlib import Path from pathlib import Path
from castle_core.config import USER_TOOL_PATH_DIRS
from castle_core.manifest import RestartPolicy, SystemdSpec from castle_core.manifest import RestartPolicy, SystemdSpec
from castle_core.registry import Deployment from castle_core.registry import Deployment
@@ -86,7 +87,8 @@ def generate_unit_from_deployed(
env_lines = "" env_lines = ""
for key, value in deployed.env.items(): for key, value in deployed.env.items():
env_lines += f"Environment={key}={value}\n" env_lines += f"Environment={key}={value}\n"
env_lines += f'Environment="PATH={Path.home() / ".local/bin"}:/usr/local/bin:/usr/bin:/bin"\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'
sd = systemd_spec sd = systemd_spec
description = deployed.description or name description = deployed.description or name

View File

@@ -8,6 +8,7 @@ import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from castle_core.config import USER_TOOL_PATH_DIRS
from castle_core.manifest import ProgramSpec from castle_core.manifest import ProgramSpec
DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"] DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"]
@@ -15,16 +16,19 @@ INSTALL_ACTIONS = ["install", "uninstall"]
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS
# Verbs a stack handler can provide (everything except `run`, which is declared-only). # Verbs a stack handler can provide (everything except `run`, which is declared-only).
_STACK_VERBS = {"build", "test", "lint", "format", "type-check", "check", "install", "uninstall"} _STACK_VERBS = {
"build",
"test",
"lint",
"format",
"type-check",
"check",
"install",
"uninstall",
}
# Verbs whose handler method name differs from the verb spelling. # Verbs whose handler method name differs from the verb spelling.
_VERB_METHOD = {"type-check": "type_check"} _VERB_METHOD = {"type-check": "type_check"}
# User-local tool directories that may not be on the systemd service PATH.
_EXTRA_PATH_DIRS = [
Path.home() / ".local" / "share" / "pnpm",
Path.home() / ".local" / "bin",
]
@dataclass @dataclass
class ActionResult: class ActionResult:
@@ -39,13 +43,15 @@ class ActionResult:
def _build_env() -> dict[str, str]: def _build_env() -> dict[str, str]:
"""Build a subprocess env with user tool dirs on PATH.""" """Build a subprocess env with user tool dirs on PATH."""
env = os.environ.copy() env = os.environ.copy()
extra = ":".join(str(d) for d in _EXTRA_PATH_DIRS if d.exists()) extra = ":".join(str(d) for d in USER_TOOL_PATH_DIRS if d.exists())
if extra: if extra:
env["PATH"] = extra + ":" + env.get("PATH", "") env["PATH"] = extra + ":" + env.get("PATH", "")
return env return env
async def _run(cmd: list[str], cwd: Path, env: dict[str, str] | None = None) -> tuple[int, str]: 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 a subprocess and return (returncode, combined output)."""
run_env = _build_env() run_env = _build_env()
if env: if env:
@@ -93,7 +99,9 @@ class StackHandler:
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError raise NotImplementedError
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def type_check(
self, name: str, comp: ProgramSpec, root: Path
) -> ActionResult:
raise NotImplementedError raise NotImplementedError
async def check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -127,40 +135,59 @@ class PythonHandler(StackHandler):
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["uv", "sync"], src) rc, output = await _run(["uv", "sync"], src)
return ActionResult( return ActionResult(
program=name, action="build", status="ok" if rc == 0 else "error", output=output program=name,
action="build",
status="ok" if rc == 0 else "error",
output=output,
) )
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
if not (src / "tests").exists(): if not (src / "tests").exists():
return ActionResult( return ActionResult(
program=name, action="test", status="ok", program=name,
action="test",
status="ok",
output="No tests directory found, skipping.", output="No tests directory found, skipping.",
) )
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src) rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
return ActionResult( return ActionResult(
program=name, action="test", status="ok" if rc == 0 else "error", output=output program=name,
action="test",
status="ok" if rc == 0 else "error",
output=output,
) )
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["uv", "run", "ruff", "check", "."], src) rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
return ActionResult( return ActionResult(
program=name, action="lint", status="ok" if rc == 0 else "error", output=output program=name,
action="lint",
status="ok" if rc == 0 else "error",
output=output,
) )
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["uv", "run", "ruff", "format", "."], src) rc, output = await _run(["uv", "run", "ruff", "format", "."], src)
return ActionResult( return ActionResult(
program=name, action="format", status="ok" if rc == 0 else "error", output=output program=name,
action="format",
status="ok" if rc == 0 else "error",
output=output,
) )
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def type_check(
self, name: str, comp: ProgramSpec, root: Path
) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["uv", "run", "pyright"], src) rc, output = await _run(["uv", "run", "pyright"], src)
return ActionResult( return ActionResult(
program=name, action="type-check", status="ok" if rc == 0 else "error", output=output program=name,
action="type-check",
status="ok" if rc == 0 else "error",
output=output,
) )
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -172,7 +199,10 @@ class PythonHandler(StackHandler):
["uv", "tool", "install", "--editable", pkg_spec, "--force"], src ["uv", "tool", "install", "--editable", pkg_spec, "--force"], src
) )
return ActionResult( return ActionResult(
program=name, action="install", status="ok" if rc == 0 else "error", output=output program=name,
action="install",
status="ok" if rc == 0 else "error",
output=output,
) )
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -185,7 +215,10 @@ class PythonHandler(StackHandler):
pkg_name = data.get("project", {}).get("name", pkg_name) pkg_name = data.get("project", {}).get("name", pkg_name)
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src) rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
return ActionResult( return ActionResult(
program=name, action="uninstall", status="ok" if rc == 0 else "error", output=output program=name,
action="uninstall",
status="ok" if rc == 0 else "error",
output=output,
) )
@@ -196,37 +229,56 @@ class ReactViteHandler(StackHandler):
src = _source_dir(comp, root) src = _source_dir(comp, root)
# Build against the gateway serve prefix so absolute asset URLs resolve at # Build against the gateway serve prefix so absolute asset URLs resolve at
# /<name>/ (vite.config reads VITE_BASE). Removes the hand-tuned-base footgun. # /<name>/ (vite.config reads VITE_BASE). Removes the hand-tuned-base footgun.
rc, output = await _run(["pnpm", "build"], src, env={"VITE_BASE": _vite_base(name)}) rc, output = await _run(
["pnpm", "build"], src, env={"VITE_BASE": _vite_base(name)}
)
return ActionResult( return ActionResult(
program=name, action="build", status="ok" if rc == 0 else "error", output=output program=name,
action="build",
status="ok" if rc == 0 else "error",
output=output,
) )
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "test"], src) rc, output = await _run(["pnpm", "test"], src)
return ActionResult( return ActionResult(
program=name, action="test", status="ok" if rc == 0 else "error", output=output program=name,
action="test",
status="ok" if rc == 0 else "error",
output=output,
) )
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "lint"], src) rc, output = await _run(["pnpm", "lint"], src)
return ActionResult( return ActionResult(
program=name, action="lint", status="ok" if rc == 0 else "error", output=output program=name,
action="lint",
status="ok" if rc == 0 else "error",
output=output,
) )
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "format"], src) rc, output = await _run(["pnpm", "format"], src)
return ActionResult( return ActionResult(
program=name, action="format", status="ok" if rc == 0 else "error", output=output program=name,
action="format",
status="ok" if rc == 0 else "error",
output=output,
) )
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def type_check(
self, name: str, comp: ProgramSpec, root: Path
) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "type-check"], src) rc, output = await _run(["pnpm", "type-check"], src)
return ActionResult( return ActionResult(
program=name, action="type-check", status="ok" if rc == 0 else "error", output=output program=name,
action="type-check",
status="ok" if rc == 0 else "error",
output=output,
) )
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -235,18 +287,24 @@ class ReactViteHandler(StackHandler):
result = await self.build(name, comp, root) result = await self.build(name, comp, root)
if result.status != "ok": if result.status != "ok":
return ActionResult( return ActionResult(
program=name, action="install", status="error", program=name,
action="install",
status="error",
output=f"Build failed:\n{result.output}", output=f"Build failed:\n{result.output}",
) )
outputs = comp.build.outputs if comp.build else [] outputs = comp.build.outputs if comp.build else []
if not outputs: if not outputs:
return ActionResult( return ActionResult(
program=name, action="install", status="error", program=name,
action="install",
status="error",
output="No build outputs configured.", output="No build outputs configured.",
) )
dist = _source_dir(comp, root) / outputs[0] dist = _source_dir(comp, root) / outputs[0]
return ActionResult( return ActionResult(
program=name, action="install", status="ok", program=name,
action="install",
status="ok",
output=f"Built; served in place from {dist}", output=f"Built; served in place from {dist}",
) )
@@ -256,7 +314,9 @@ class ReactViteHandler(StackHandler):
Deactivating one means dropping its gateway route — handled by removing the Deactivating one means dropping its gateway route — handled by removing the
program from the registry, not by deleting build output.""" program from the registry, not by deleting build output."""
return ActionResult( return ActionResult(
program=name, action="uninstall", status="ok", program=name,
action="uninstall",
status="ok",
output=f"{name}: served in place; nothing to uninstall.", output=f"{name}: served in place; nothing to uninstall.",
) )
@@ -291,7 +351,11 @@ def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
def _stack_provides(comp: ProgramSpec, verb: str) -> bool: def _stack_provides(comp: ProgramSpec, verb: str) -> bool:
"""Whether the program's stack handler can run this verb.""" """Whether the program's stack handler can run this verb."""
return bool(comp.source) and verb in _STACK_VERBS and get_handler(comp.stack) is not None return (
bool(comp.source)
and verb in _STACK_VERBS
and get_handler(comp.stack) is not None
)
def is_available(comp: ProgramSpec, verb: str) -> bool: def is_available(comp: ProgramSpec, verb: str) -> bool:
@@ -319,11 +383,15 @@ async def _run_declared(
rc, output = await _run(argv, src) rc, output = await _run(argv, src)
outputs.append(output) outputs.append(output)
if rc != 0: if rc != 0:
return ActionResult(program=name, action=verb, status="error", output="".join(outputs)) return ActionResult(
program=name, action=verb, status="error", output="".join(outputs)
)
return ActionResult(program=name, action=verb, status="ok", output="".join(outputs)) return ActionResult(program=name, action=verb, status="ok", output="".join(outputs))
async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def run_action(
verb: str, name: str, comp: ProgramSpec, root: Path
) -> ActionResult:
"""Resolve and run a verb: declared command → stack default → unavailable. """Resolve and run a verb: declared command → stack default → unavailable.
This is the single entry point callers should use; it replaces reaching for This is the single entry point callers should use; it replaces reaching for
@@ -335,7 +403,9 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)] subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)]
if not subs: if not subs:
return ActionResult( return ActionResult(
program=name, action="check", status="error", program=name,
action="check",
status="error",
output="No checkable verbs available.", output="No checkable verbs available.",
) )
sections: list[str] = [] sections: list[str] = []
@@ -346,7 +416,9 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
sections.append(f"{mark} {sub}" + (f"\n{body}" if body else "")) sections.append(f"{mark} {sub}" + (f"\n{body}" if body else ""))
if result.status != "ok": if result.status != "ok":
return ActionResult( return ActionResult(
program=name, action="check", status="error", program=name,
action="check",
status="error",
output="\n\n".join(sections), output="\n\n".join(sections),
) )
return ActionResult( return ActionResult(
@@ -359,7 +431,9 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
try: try:
src = _source_dir(comp, root) src = _source_dir(comp, root)
except ValueError: except ValueError:
return ActionResult(program=name, action=verb, status="error", output="No source directory") return ActionResult(
program=name, action=verb, status="error", output="No source directory"
)
return await _run_declared(name, verb, declared, src) return await _run_declared(name, verb, declared, src)
# 2. Stack default. # 2. Stack default.
@@ -371,7 +445,9 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
# 3. Unavailable. # 3. Unavailable.
return ActionResult( return ActionResult(
program=name, action=verb, status="error", program=name,
action=verb,
status="error",
output=f"Verb '{verb}' is not available for '{name}' " output=f"Verb '{verb}' is not available for '{name}' "
f"(no declared command and no stack handler provides it).", f"(no declared command and no stack handler provides it).",
) )