From 290bcb63837d99fb0e893240c1897f929b111a39 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 16 Jun 2026 17:47:49 -0700 Subject: [PATCH] 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. --- core/src/castle_core/config.py | 26 ++-- core/src/castle_core/generators/systemd.py | 4 +- core/src/castle_core/stacks.py | 150 ++++++++++++++++----- 3 files changed, 133 insertions(+), 47 deletions(-) diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 9bb8b20..4804772 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -75,6 +75,20 @@ DATA_DIR = _resolve_data_dir() SECRETS_DIR = CASTLE_HOME / "secrets" 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) GENERATED_DIR = SPECS_DIR STATIC_DIR = CONTENT_DIR @@ -97,8 +111,7 @@ def find_castle_root() -> Path: return current current = current.parent raise FileNotFoundError( - "Could not find castle.yaml.\n" - f"Expected at: {CASTLE_HOME / 'castle.yaml'}" + f"Could not find castle.yaml.\nExpected at: {CASTLE_HOME / 'castle.yaml'}" ) @@ -125,11 +138,7 @@ class CastleConfig: @property def tools(self) -> dict[str, ProgramSpec]: """Return programs that are tools (behavior == 'tool').""" - return { - k: v - for k, v in self.programs.items() - if v.behavior == "tool" - } + return {k: v for k, v in self.programs.items() if v.behavior == "tool"} @property def frontends(self) -> dict[str, ProgramSpec]: @@ -230,8 +239,7 @@ def _expand_units( for name, unit in units.items(): if name in programs or name in services or name in jobs: raise ValueError( - f"Unit '{name}' conflicts with existing entry in " - f"programs/services/jobs" + f"Unit '{name}' conflicts with existing entry in programs/services/jobs" ) defaults = _STACK_DEFAULTS.get(unit.stack or "", {}) diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index 08a1a7e..35ce2e6 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -5,6 +5,7 @@ from __future__ import annotations import shutil from pathlib import Path +from castle_core.config import USER_TOOL_PATH_DIRS from castle_core.manifest import RestartPolicy, SystemdSpec from castle_core.registry import Deployment @@ -86,7 +87,8 @@ def generate_unit_from_deployed( env_lines = "" for key, value in deployed.env.items(): 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 description = deployed.description or name diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 3ae5e9f..4982390 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -8,6 +8,7 @@ import tomllib from dataclasses import dataclass from pathlib import Path +from castle_core.config import USER_TOOL_PATH_DIRS from castle_core.manifest import ProgramSpec DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"] @@ -15,16 +16,19 @@ INSTALL_ACTIONS = ["install", "uninstall"] ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS # 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. _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 class ActionResult: @@ -39,13 +43,15 @@ class ActionResult: def _build_env() -> dict[str, str]: """Build a subprocess env with user tool dirs on PATH.""" 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: env["PATH"] = extra + ":" + env.get("PATH", "") 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_env = _build_env() if env: @@ -93,7 +99,9 @@ class StackHandler: async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: 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 async def check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: @@ -127,40 +135,59 @@ class PythonHandler(StackHandler): src = _source_dir(comp, root) rc, output = await _run(["uv", "sync"], src) 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: src = _source_dir(comp, root) if not (src / "tests").exists(): return ActionResult( - program=name, action="test", status="ok", + program=name, + action="test", + status="ok", output="No tests directory found, skipping.", ) rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src) 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: src = _source_dir(comp, root) rc, output = await _run(["uv", "run", "ruff", "check", "."], src) 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: src = _source_dir(comp, root) rc, output = await _run(["uv", "run", "ruff", "format", "."], src) 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) rc, output = await _run(["uv", "run", "pyright"], src) 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: @@ -172,7 +199,10 @@ class PythonHandler(StackHandler): ["uv", "tool", "install", "--editable", pkg_spec, "--force"], src ) 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: @@ -185,7 +215,10 @@ class PythonHandler(StackHandler): pkg_name = data.get("project", {}).get("name", pkg_name) rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src) 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) # Build against the gateway serve prefix so absolute asset URLs resolve at # // (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( - 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: src = _source_dir(comp, root) rc, output = await _run(["pnpm", "test"], src) 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: src = _source_dir(comp, root) rc, output = await _run(["pnpm", "lint"], src) 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: src = _source_dir(comp, root) rc, output = await _run(["pnpm", "format"], src) 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) rc, output = await _run(["pnpm", "type-check"], src) 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: @@ -235,18 +287,24 @@ class ReactViteHandler(StackHandler): result = await self.build(name, comp, root) if result.status != "ok": return ActionResult( - program=name, action="install", status="error", + program=name, + action="install", + status="error", output=f"Build failed:\n{result.output}", ) outputs = comp.build.outputs if comp.build else [] if not outputs: return ActionResult( - program=name, action="install", status="error", + program=name, + action="install", + status="error", output="No build outputs configured.", ) dist = _source_dir(comp, root) / outputs[0] return ActionResult( - program=name, action="install", status="ok", + program=name, + action="install", + status="ok", 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 program from the registry, not by deleting build output.""" return ActionResult( - program=name, action="uninstall", status="ok", + program=name, + action="uninstall", + status="ok", 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: """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: @@ -319,11 +383,15 @@ async def _run_declared( rc, output = await _run(argv, src) outputs.append(output) 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)) -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. 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)] if not subs: return ActionResult( - program=name, action="check", status="error", + program=name, + action="check", + status="error", output="No checkable verbs available.", ) 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 "")) if result.status != "ok": return ActionResult( - program=name, action="check", status="error", + program=name, + action="check", + status="error", output="\n\n".join(sections), ) return ActionResult( @@ -359,7 +431,9 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act try: src = _source_dir(comp, root) 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) # 2. Stack default. @@ -371,7 +445,9 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act # 3. Unavailable. return ActionResult( - program=name, action=verb, status="error", + program=name, + action=verb, + status="error", output=f"Verb '{verb}' is not available for '{name}' " f"(no declared command and no stack handler provides it).", )