From 0463cd91f16102f7f78e3bc9c5256406a591600f Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 13:07:00 -0700 Subject: [PATCH] Fix tool install-state, inherit program description, declutter Tools cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tool detail showed installed tools as 'Not installed': /deployments/{name} populated 'installed' for path deployments but left 'active' null, and ToolDetail keyed off 'active'. Now the endpoint sets active=installed for path, and ToolDetail reads 'installed' directly (verified: on-PATH ⇒ True, else False). - A deployment now inherits its program's description by default: copied at creation (CLI create + API _save_deployment on new deployments) + a one-time patchup of existing ones (29 deployments). - Tools page cards no longer list deployments (ProgramCard showDeployments=false) — the lens already scopes to the tool; the deployment list is a Programs-catalog thing. --- app/src/components/ProgramCard.tsx | 37 ++++++++++++++-------- app/src/components/ProgramList.tsx | 10 ++++-- app/src/pages/ToolDetail.tsx | 15 +++++---- app/src/pages/Tools.tsx | 2 +- castle-api/src/castle_api/config_editor.py | 21 ++++++++---- castle-api/src/castle_api/routes.py | 6 +++- cli/src/castle_cli/commands/create.py | 6 ++-- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/app/src/components/ProgramCard.tsx b/app/src/components/ProgramCard.tsx index 4d0cc10..c38743a 100644 --- a/app/src/components/ProgramCard.tsx +++ b/app/src/components/ProgramCard.tsx @@ -9,9 +9,16 @@ interface ProgramCardProps { // "/tools" so a tool card opens its tool detail page (a tool is 1:1 with its // program, same name). linkBase?: string + // Whether to list the program's deployments. The Programs catalog shows them; + // a kind-specific lens (e.g. Tools) is already looking at one deployment, so off. + showDeployments?: boolean } -export function ProgramCard({ program, linkBase = "/programs" }: ProgramCardProps) { +export function ProgramCard({ + program, + linkBase = "/programs", + showDeployments = true, +}: ProgramCardProps) { // The dot reflects the uniform lifecycle state (a tool on PATH, a service // running, a static site served). Lifecycle controls live on the detail page's // Deployment section, not here — a card just shows state and links through. @@ -38,19 +45,21 @@ export function ProgramCard({ program, linkBase = "/programs" }: ProgramCardProp - {/* A program has no kind of its own — show its deployments (name · kind). */} - {program.deployments.length > 0 ? ( -
- {program.deployments.map((d) => ( -
- {d.name} - -
- ))} -
- ) : ( -

no deployment

- )} + {/* A program has no kind of its own — show its deployments (name · kind). + Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */} + {showDeployments && + (program.deployments.length > 0 ? ( +
+ {program.deployments.map((d) => ( +
+ {d.name} + +
+ ))} +
+ ) : ( +

no deployment

+ ))} {program.description && (

{program.description}

diff --git a/app/src/components/ProgramList.tsx b/app/src/components/ProgramList.tsx index 2b2a9ec..fc04bc4 100644 --- a/app/src/components/ProgramList.tsx +++ b/app/src/components/ProgramList.tsx @@ -5,9 +5,10 @@ import { ProgramCard } from "./ProgramCard" interface ProgramListProps { programs: ProgramSummary[] linkBase?: string // where each card links (default "/programs") + showDeployments?: boolean // list each program's deployments on the card (default true) } -export function ProgramList({ programs, linkBase }: ProgramListProps) { +export function ProgramList({ programs, linkBase, showDeployments }: ProgramListProps) { const [search, setSearch] = useState("") const filtered = useMemo(() => { @@ -39,7 +40,12 @@ export function ProgramList({ programs, linkBase }: ProgramListProps) { ) : (
{filtered.map((program) => ( - + ))}
)} diff --git a/app/src/pages/ToolDetail.tsx b/app/src/pages/ToolDetail.tsx index f3ae599..b747dfa 100644 --- a/app/src/pages/ToolDetail.tsx +++ b/app/src/pages/ToolDetail.tsx @@ -38,8 +38,9 @@ export function ToolDetailPage() {

{deployment.description}

)} - {/* A tool's PATH deployment: install/uninstall is its start/stop. */} - + {/* A tool's PATH deployment: install/uninstall is its start/stop. Its + live state is `installed` (on PATH), which the endpoint sets directly. */} +

@@ -65,19 +66,19 @@ export function ToolDetailPage() { /** Install/uninstall a tool on PATH — the path deployment's lifecycle. */ function PathLifecycle({ name, - active, + installed: installedState, onDone, }: { name: string - active: boolean | null + installed: boolean | null onDone: () => void }) { const { mutate, isPending } = useProgramAction() - const installed = active === true + const installed = installedState === true const dot = - active === true + installedState === true ? "bg-green-500" - : active === false + : installedState === false ? "bg-[var(--muted)]" : "bg-transparent border border-[var(--muted)]" return ( diff --git a/app/src/pages/Tools.tsx b/app/src/pages/Tools.tsx index bcded37..c693cc0 100644 --- a/app/src/pages/Tools.tsx +++ b/app/src/pages/Tools.tsx @@ -14,7 +14,7 @@ export function Tools() { {isLoading ? (

Loading...

) : programs && programs.length > 0 ? ( - + ) : (

No tools yet.

)} diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index 8dc0d7c..9fda783 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -273,18 +273,27 @@ async def delete_program(name: str, cascade: bool = False) -> dict: def _save_deployment(name: str, config_dict: dict) -> dict: """Validate a deployment (any manager) and persist it to config.deployments.""" _require_repo() + config = get_config() + config_dict = dict(config_dict) + + # On CREATE (a new deployment) with no description of its own, inherit the + # referenced program's description — a deployment reads as its program by + # default. Edits keep whatever the user set (including a cleared field). + if name not in config.deployments and not config_dict.get("description"): + prog = config_dict.get("program") + if prog and prog in config.programs and config.programs[prog].description: + config_dict["description"] = config.programs[prog].description + try: - dep_data = _normalize_deployment_dict({**config_dict, "id": name}) - _DEPLOYMENT_ADAPTER.validate_python(dep_data) + dep = _DEPLOYMENT_ADAPTER.validate_python( + _normalize_deployment_dict({**config_dict, "id": name}) + ) except Exception as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid deployment config: {e}", ) - config = get_config() - config.deployments[name] = _DEPLOYMENT_ADAPTER.validate_python( - _normalize_deployment_dict({**config_dict, "id": name}) - ) + config.deployments[name] = dep save_config(config) return {"ok": True, "deployment": name} diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 8b9995c..aaeaba9 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -77,10 +77,13 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: timer=has_timer, ) - # A PATH-managed deployment (a tool) is "installed" when it's on PATH. + # A PATH-managed deployment (a tool) is "installed" — and thus active — when + # it's on PATH. (systemd/caddy liveness comes from the health/status stream.) installed: bool | None = None + active: bool | None = None if deployed.manager == "path": installed = shutil.which(name) is not None + active = installed category = "job" if deployed.schedule else "service" @@ -99,6 +102,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: systemd=systemd_info, schedule=deployed.schedule, installed=installed, + active=active, ) diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 2e98633..83e3b69 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -115,12 +115,13 @@ def run_create(args: argparse.Namespace) -> int: if kind == "tool": # A PATH-managed deployment: installed via `uv tool install`, no unit/route. config.deployments[name] = PathDeployment( - id=name, manager="path", program=name + id=name, manager="path", program=name, description=description ) elif kind == "static": # A caddy-managed static deployment: no systemd unit, served from the build dir. config.deployments[name] = CaddyDeployment( - id=name, manager="caddy", program=name, root=static_root or "dist" + id=name, manager="caddy", program=name, root=static_root or "dist", + description=description, ) elif kind == "service": prefix = name.replace("-", "_").upper() @@ -128,6 +129,7 @@ def run_create(args: argparse.Namespace) -> int: id=name, manager="systemd", program=name, + description=description, run=RunPython(launcher="python", program=name), expose=ExposeSpec( http=HttpExposeSpec(