diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 66f5c5a..adabf2a 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status from castle_core.config import SPECS_DIR from castle_core.generators.caddyfile import generate_caddyfile_from_registry -from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec +from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec, manager_for from castle_core.stacks import available_actions from castle_api.config import get_castle_root, get_registry @@ -70,9 +70,9 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: timer=has_timer, ) - # Check if tool is installed on PATH + # A PATH-managed deployment (a tool) is "installed" when it's on PATH. installed: bool | None = None - if deployed.behavior == "tool": + if manager_for(deployed.runner) == "path": installed = shutil.which(name) is not None category = "job" if deployed.schedule else "service" diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index be1828f..e5e69b0 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -14,6 +14,7 @@ from castle_cli.manifest import ( HttpInternal, ManageSpec, ProgramSpec, + RunPath, RunPython, RunStatic, ServiceSpec, @@ -109,7 +110,12 @@ def run_create(args: argparse.Namespace) -> int: behavior=behavior, build=build, ) - if behavior == "frontend": + if behavior == "tool": + # A PATH-managed deployment: installed via `uv tool install`, no unit/route. + config.services[name] = ServiceSpec( + id=name, program=name, run=RunPath(runner="path") + ) + elif behavior == "frontend": # A caddy-managed static service: no systemd unit, served from the build dir. config.services[name] = ServiceSpec( id=name, diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index e3b5f10..fe7248a 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -133,20 +133,13 @@ def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int: """A `path` (tool) deployment's lifecycle is install/uninstall on PATH.""" import asyncio - from castle_core.stacks import run_action + from castle_core.lifecycle import activate, deactivate - program = config.services[name].program or name - comp = config.programs.get(program) - if comp is None: - print(f"Error: path service '{name}' references unknown program '{program}'.") - return 1 - verb = {"start": "install", "stop": "uninstall", "restart": "install"}[action] - res = asyncio.run(run_action(verb, program, comp, config.root)) - ok = res.status == "ok" - print(f" {name}: {verb + 'ed' if ok else verb + ' FAILED'}") - if not ok and res.output: - print(res.output) - return 0 if ok else 1 + # stop → uninstall; start/restart → ensure installed (activate skips if on PATH). + coro = deactivate if action == "stop" else activate + res = asyncio.run(coro(name, config, config.root)) + print(f" {res.output}") + return 0 if res.status == "ok" else 1 def _services_restart(config: CastleConfig) -> int: @@ -218,30 +211,23 @@ def _service_disable(config: CastleConfig, name: str) -> int: def _service_status(config: CastleConfig) -> int: - """Show status of all managed services and jobs.""" + """Show status of all services and jobs, dispatched by manager.""" + from castle_core.lifecycle import is_active + print("\nCastle Services") print("=" * 50) for name, svc in config.services.items(): - svc_unit = unit_name(name) - result = subprocess.run( - ["systemctl", "--user", "is-active", svc_unit], - capture_output=True, - text=True, - ) - status = result.stdout.strip() - if status == "active": - color = "\033[92m" - elif status == "inactive": - color = "\033[90m" - else: - color = "\033[91m" + active = is_active(name, config) # manager-aware (systemd/caddy/path/none) + manager = manager_for(svc.run.runner) + color = "\033[92m" if active else "\033[90m" reset = "\033[0m" + label = "active" if active else "inactive" port_str = "" if svc.expose and svc.expose.http: port_str = f":{svc.expose.http.internal.port}" - print(f" {color}{status:10s}{reset} {name}{port_str}") + print(f" {color}{label:10s}{reset} {name}{port_str} \033[90m[{manager}]{reset}") if config.jobs: print(f"\n{'─' * 50}") diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 3fea24f..1ecb86a 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -75,6 +75,8 @@ class TestCreateCommand: assert "my-tool2" in config.programs comp = config.programs["my-tool2"] assert comp.behavior == "tool" + # A tool is a PATH deployment: a `runner: path` service. + assert config.services["my-tool2"].run.runner == "path" def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None: """A supabase app scaffolds a Patch-shaped project registered as a static diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 4b828aa..d272d0a 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -139,8 +139,12 @@ 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"} + """Programs deployed as a PATH tool (a `runner: path` service) — derived + from deployments, not the `behavior` label.""" + tool_programs = { + s.program or n for n, s in self.services.items() if s.run.runner == "path" + } + return {k: v for k, v in self.programs.items() if k in tool_programs} @property def frontends(self) -> dict[str, ProgramSpec]: diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 3f0d678..15b594f 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -614,6 +614,9 @@ def _build_run_cmd( case "static": # No process — the gateway file_servers this service's root. return [] + case "path": + # No process — installed on PATH via `uv tool install` at enable time. + return [] case _: raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr] diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 019000b..30656b8 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -198,6 +198,8 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult: prog, comp = _program_for(name, config) if comp is None: return ActionResult(name, "activate", "error", f"unknown program '{prog}'") + if _on_path(prog): # already installed — skip the (slow) editable reinstall + return ActionResult(name, "activate", "ok", f"{name}: on PATH") return await run_action("install", prog, comp, root) if manager == "none": diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 9e93506..68c0cc6 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -89,9 +89,27 @@ class RunStatic(RunBase): root: str = "dist" # served dir, relative to the program source +class RunPath(RunBase): + """A CLI installed on the user's PATH via ``uv tool install`` — no process. + + Like ``remote``/``static`` it has no systemd unit; its manager is **PATH**. + The referenced program is what gets installed; its lifecycle is + install/uninstall (which is what start/stop/enable/disable map to). + """ + + runner: Literal["path"] + + RunSpec = Annotated[ Union[ - RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote, RunStatic + RunCommand, + RunPython, + RunContainer, + RunNode, + RunCompose, + RunRemote, + RunStatic, + RunPath, ], Field(discriminator="runner"), ] diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 009ed03..6e9e00c 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -42,6 +42,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: }, }, "services": { + "test-tool": { + "program": "test-tool", + "run": {"runner": "path"}, + }, "test-svc": { "program": "test-svc-comp", "description": "Test service", diff --git a/core/tests/test_lifecycle.py b/core/tests/test_lifecycle.py index 0b7fdc4..f203f4e 100644 --- a/core/tests/test_lifecycle.py +++ b/core/tests/test_lifecycle.py @@ -34,6 +34,28 @@ class TestIsActive: config = load_config(castle_root) assert lifecycle.is_active("does-not-exist", config) is False + def test_path_service_checks_path(self, castle_root: Path) -> None: + # A `runner: path` service (a tool) is active when on PATH. + from castle_core.manifest import ProgramSpec, RunPath, ServiceSpec, manager_for + + assert manager_for("path") == "path" + config = load_config(castle_root) + config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool") + config.services["mytool"] = ServiceSpec(program="mytool", run=RunPath(runner="path")) + with patch.object(lifecycle, "_on_path", return_value=True) as mock: + assert lifecycle.is_active("mytool", config) is True + mock.assert_called_once_with("mytool") + + def test_remote_service_is_active(self, castle_root: Path) -> None: + # A remote service has no local process; the manager is `none` → available. + from castle_core.manifest import RunRemote, ServiceSpec + + config = load_config(castle_root) + config.services["ext"] = ServiceSpec( + program="ext", run=RunRemote(runner="remote", base_url="http://x") + ) + assert lifecycle.is_active("ext", config) is True + def test_static_service_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None: # A frontend is a `runner: static` service; active once its served dir exists. from castle_core.manifest import ProgramSpec, RunStatic, ServiceSpec