From 56b9c8ddf107ace075de9d610ecd4220ddab0d5a Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 23 Feb 2026 16:32:55 -0800 Subject: [PATCH] refactor: Update component structure to use behavior and stack attributes, enhancing clarity and functionality across services, tools, and frontends --- CLAUDE.md | 39 ++-- README.md | 83 ++++++--- app/src/components/BehaviorBadge.tsx | 24 +++ app/src/components/ComponentCard.tsx | 8 +- app/src/components/ComponentEditor.tsx | 6 +- app/src/components/ComponentFields.tsx | 2 +- app/src/components/ComponentGrid.tsx | 20 +- app/src/components/ComponentTable.tsx | 98 +++------- app/src/components/JobSection.tsx | 105 ----------- app/src/components/RoleBadge.tsx | 24 --- app/src/components/ScheduledSection.tsx | 107 +++++++++++ app/src/components/SectionHeader.tsx | 6 +- app/src/components/ServiceSection.tsx | 2 +- app/src/components/StackBadge.tsx | 11 ++ app/src/components/ToolSection.tsx | 120 ------------ app/src/lib/labels.ts | 48 +++-- app/src/pages/ComponentDetail.tsx | 12 +- app/src/pages/Dashboard.tsx | 79 ++------ app/src/pages/NodeDetail.tsx | 11 +- app/src/types/index.ts | 3 +- castle-api/src/castle_api/models.py | 3 +- castle-api/src/castle_api/mqtt_client.py | 7 +- castle-api/src/castle_api/nodes.py | 3 +- castle-api/src/castle_api/routes.py | 49 +++-- castle-api/tests/conftest.py | 2 +- castle-api/tests/test_health.py | 6 +- castle-api/tests/test_mqtt.py | 11 +- castle-api/tests/test_nodes.py | 2 +- castle.yaml | 21 +++ cli/src/castle_cli/commands/create.py | 37 ++-- cli/src/castle_cli/commands/deploy.py | 16 +- cli/src/castle_cli/commands/info.py | 44 +++-- cli/src/castle_cli/commands/list_cmd.py | 173 +++++++++++++----- cli/src/castle_cli/main.py | 18 +- cli/src/castle_cli/templates/scaffold.py | 12 +- cli/tests/test_create.py | 29 +-- cli/tests/test_info.py | 12 +- cli/tests/test_list.py | 18 +- core/src/castle_core/manifest.py | 1 + core/src/castle_core/registry.py | 15 +- docs/component-registry.md | 10 +- docs/design.md | 52 +++--- .../{python-tools.md => stacks/python-cli.md} | 2 +- .../{web-apis.md => stacks/python-fastapi.md} | 6 +- .../react-vite.md} | 8 +- 45 files changed, 698 insertions(+), 667 deletions(-) create mode 100644 app/src/components/BehaviorBadge.tsx delete mode 100644 app/src/components/JobSection.tsx delete mode 100644 app/src/components/RoleBadge.tsx create mode 100644 app/src/components/ScheduledSection.tsx create mode 100644 app/src/components/StackBadge.tsx delete mode 100644 app/src/components/ToolSection.tsx rename docs/{python-tools.md => stacks/python-cli.md} (99%) rename docs/{web-apis.md => stacks/python-fastapi.md} (97%) rename docs/{web-frontends.md => stacks/react-vite.md} (97%) diff --git a/CLAUDE.md b/CLAUDE.md index 6aff873..e8fefe5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,11 @@ Castle is a personal software platform — a monorepo of independent projects - **`services:`** — Long-running daemons (run, expose, proxy, systemd) - **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer) -The section determines the category — no role derivation. Services and jobs -can reference a component via `component:` for description fallthrough. +Each component has a **stack** (development toolchain: python-fastapi, +python-cli, react-vite) and a **behavior** (runtime role: daemon, tool, +frontend). Scheduling, systemd management, and proxying are orthogonal +operations. Services and jobs reference a component via `component:` for +description fallthrough. **Key principle:** Regular projects must never depend on castle. They accept standard configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway) @@ -25,22 +28,22 @@ know about castle internals. When creating a new service, tool, or frontend, follow the detailed guides: - @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle -- @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing) -- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) -- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns +- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing) +- @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) +- @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns ### Quick start ```bash -# Service -castle create my-service --type service --description "Does something" +# Daemon (python-fastapi) +castle create my-service --stack python-fastapi --description "Does something" cd components/my-service && uv sync uv run my-service # starts on auto-assigned port castle service enable my-service # register with systemd castle gateway reload # update reverse proxy routes -# Tool -castle create my-tool --type tool --description "Does something" +# Tool (python-cli) +castle create my-tool --stack python-cli --description "Does something" cd components/my-tool && uv sync ``` @@ -53,9 +56,11 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`. ```bash castle list # List all components -castle list --type service # Filter by category +castle list --behavior daemon # Filter by behavior +castle list --stack python-cli # Filter by stack castle info # Show details (--json for machine-readable) -castle create --type service # Scaffold new project +castle create --stack python-fastapi # Scaffold new project +castle deploy [component] # Deploy to runtime (registry + systemd + Caddyfile) castle test [project] # Run tests (one or all) castle lint [project] # Run linter (one or all) castle sync # Update submodules + uv sync all @@ -73,7 +78,14 @@ castle services start|stop # Start/stop everything - **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml` into `~/.castle/generated/Caddyfile`. Dashboard served at root. -- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service` +- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`. + Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy` + shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`). +- **Containers**: `runner: container` services use Docker (preferred on this system + due to rootless podman UID mapping issues). Deploy resolves the runtime via + `shutil.which("docker")`. +- **MQTT**: Mosquitto broker runs as `castle-mqtt` (Docker container on port 1883). + Data in `/data/castle/castle-mqtt/`, config in `/data/castle/castle-mqtt/config/`. - **Data**: Service data lives in `/data/castle//`, passed via env var. - **Secrets**: `~/.castle/secrets/` — never in project directories. @@ -93,7 +105,8 @@ Gateway: - `GET /gateway/caddyfile` — Generated Caddyfile content - `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy -Nodes (mesh): +Mesh: +- `GET /mesh/status` — MQTT connection state, broker info, peer list - `GET /nodes` — All known nodes (local + discovered remote) - `GET /nodes/{hostname}` — Node detail with deployed components diff --git a/README.md b/README.md index e8c2309..e57e258 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,23 @@ open http://localhost:9000 ```bash # Service — FastAPI app with health endpoint, systemd unit, gateway route -castle create my-api --type service --description "Does something useful" +castle create my-api --stack python-fastapi --description "Does something useful" cd components/my-api && uv sync castle test my-api castle service enable my-api castle gateway reload # Standalone tool — CLI tool with argparse, stdin/stdout, Unix pipes -castle create my-tool --type tool --description "Does something" +castle create my-tool --stack python-cli --description "Does something" ``` ## CLI Reference ``` -castle list [--role ROLE] [--json] List all components -castle info NAME [--json] Show component details -castle create NAME --type TYPE Scaffold a new component +castle list [--behavior B] [--stack S] [--json] List all components +castle info NAME [--json] Show component details +castle create NAME --stack STACK Scaffold a new component +castle deploy [NAME] Deploy component(s) to runtime castle run NAME Run component in foreground castle test [NAME] Run tests (one or all) castle lint [NAME] Run linter (one or all) @@ -56,7 +57,13 @@ castle tool info NAME Show tool details ## Registry -`castle.yaml` is the single source of truth. Components declare **what they do** (run, expose, manage, install, build, triggers) and roles are **derived** from those declarations. +`castle.yaml` is the single source of truth with three sections: + +- **`components:`** — Software catalog (source, install, tool metadata, build) +- **`services:`** — Long-running daemons (run, expose, proxy, systemd) +- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer) + +Services and jobs can reference a component via `component:` for description fallthrough. ```yaml gateway: @@ -66,6 +73,10 @@ components: central-context: description: Content storage API source: components/central-context + +services: + central-context: + component: central-context run: runner: python tool: central-context @@ -78,22 +89,13 @@ components: manage: systemd: {} - notification-bridge: - description: Desktop notification forwarder - source: components/notification-bridge +jobs: + backup-collect: + component: backup-collect run: - runner: python - tool: notification-bridge - defaults: - env: - CENTRAL_CONTEXT_URL: http://localhost:9001 - BUCKET_NAME: notifications - expose: - http: - internal: { port: 9002 } - health_path: /health - proxy: - caddy: { path_prefix: /notifications } + runner: command + argv: [backup-collect] + schedule: "0 2 * * *" manage: systemd: {} ``` @@ -106,15 +108,16 @@ automatically by `castle deploy`. Only non-convention values need `defaults.env` ``` castle.yaml <- component registry (single source of truth) cli/ <- castle CLI +core/ <- castle-core library (models, config, generators) castle-api/ <- Castle API (dashboard backend) app/ <- Castle web app (React/Vite frontend) components/ <- all non-infrastructure components central-context/ <- content storage API (git submodule) notification-bridge/ <- desktop notification forwarder (git submodule) protonmail/ <- email sync tool/job - devbox-connect/ <- SSH tunnel manager pdf2md/ <- standalone tool (each tool is its own project) ... +docs/ <- architecture docs and component guides ruff.toml <- shared lint config pyrightconfig.json <- shared type checking config ``` @@ -137,6 +140,7 @@ pyrightconfig.json <- shared type checking config | central-context | 9001 | Content storage API | | notification-bridge | 9002 | Desktop notification forwarder | | castle-api | 9020 | Castle API (dashboard backend) | +| castle-mqtt | 1883 | MQTT broker for mesh coordination (Mosquitto container) | ### Jobs @@ -171,3 +175,38 @@ pyrightconfig.json <- shared type checking config | Component | Description | |-----------|-------------| | castle-app | Castle web dashboard (React/Vite/TypeScript) | + +## Mesh Coordination + +Castle nodes can discover each other via MQTT and mDNS, forming a personal infrastructure mesh. All mesh features are opt-in — single-node works without them. + +```bash +# Enable on castle-api (via systemd drop-in or env vars) +CASTLE_API_MQTT_ENABLED=true # Connect to MQTT broker +CASTLE_API_MQTT_HOST=localhost # Broker address (default) +CASTLE_API_MQTT_PORT=1883 # Broker port (default) +CASTLE_API_MDNS_ENABLED=true # Advertise/discover via mDNS +``` + +When enabled, the API publishes the node's registry to `castle/{hostname}/registry` (retained) and subscribes to other nodes. The gateway can proxy to services on remote nodes. The dashboard shows discovered nodes, cross-node routes, and mesh connection status. + +## API + +`castle-api` runs on port 9020 and is proxied at `/api` through the gateway. + +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Health check | +| `GET /stream` | SSE stream (health, service-action, mesh events) | +| `GET /components` | List all components (`?include_remote=true` for cross-node) | +| `GET /components/{name}` | Component detail | +| `GET /status` | Live health for all services | +| `GET /gateway` | Gateway info with route table | +| `GET /gateway/caddyfile` | Generated Caddyfile content | +| `POST /gateway/reload` | Regenerate Caddyfile and reload Caddy | +| `GET /mesh/status` | Mesh connection state (MQTT, mDNS, peers) | +| `GET /nodes` | All known nodes (local + remote) | +| `GET /nodes/{hostname}` | Node detail with deployed components | +| `POST /services/{name}/{action}` | Start/stop/restart a service | +| `GET /tools` | List all tools | +| `POST /tools/{name}/install` | Install tool to PATH | diff --git a/app/src/components/BehaviorBadge.tsx b/app/src/components/BehaviorBadge.tsx new file mode 100644 index 0000000..a7471f2 --- /dev/null +++ b/app/src/components/BehaviorBadge.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/lib/utils" +import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels" + +const behaviorColors: Record = { + daemon: "bg-green-700 text-white", + tool: "bg-blue-700 text-white", + frontend: "bg-yellow-600 text-black", +} + +export function BehaviorBadge({ behavior }: { behavior: string | null }) { + if (!behavior) return null + + return ( + + {behaviorLabel(behavior)} + + ) +} diff --git a/app/src/components/ComponentCard.tsx b/app/src/components/ComponentCard.tsx index 292cf67..5083a96 100644 --- a/app/src/components/ComponentCard.tsx +++ b/app/src/components/ComponentCard.tsx @@ -4,7 +4,8 @@ import type { ComponentSummary, HealthStatus } from "@/types" import { useServiceAction } from "@/services/api/hooks" import { runnerLabel } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" -import { RoleBadge } from "./RoleBadge" +import { BehaviorBadge } from "./BehaviorBadge" +import { StackBadge } from "./StackBadge" interface ComponentCardProps { component: ComponentSummary @@ -37,8 +38,9 @@ export function ComponentCard({ component, health }: ComponentCardProps) { ) : null} -
- +
+ +
{component.description && ( diff --git a/app/src/components/ComponentEditor.tsx b/app/src/components/ComponentEditor.tsx index 99ec3b6..68a171f 100644 --- a/app/src/components/ComponentEditor.tsx +++ b/app/src/components/ComponentEditor.tsx @@ -2,7 +2,8 @@ import { useState } from "react" import { ChevronDown, ChevronRight } from "lucide-react" import type { ComponentDetail } from "@/types" import { ComponentFields } from "./ComponentFields" -import { RoleBadge } from "./RoleBadge" +import { BehaviorBadge } from "./BehaviorBadge" +import { StackBadge } from "./StackBadge" interface ComponentEditorProps { component: ComponentDetail @@ -25,7 +26,8 @@ export function ComponentEditor({ component, onSave, onDelete }: ComponentEditor {component.description}
- + +
diff --git a/app/src/components/ComponentFields.tsx b/app/src/components/ComponentFields.tsx index 59980fd..56f8e25 100644 --- a/app/src/components/ComponentFields.tsx +++ b/app/src/components/ComponentFields.tsx @@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields try { const config: Record = { ...m } delete config.id - delete config.category + delete config.behavior config.description = description || undefined // Merge plain env + secret references back together diff --git a/app/src/components/ComponentGrid.tsx b/app/src/components/ComponentGrid.tsx index 61b4860..d55e394 100644 --- a/app/src/components/ComponentGrid.tsx +++ b/app/src/components/ComponentGrid.tsx @@ -1,8 +1,8 @@ import type { ComponentSummary, HealthStatus } from "@/types" -import { CATEGORY_LABELS } from "@/lib/labels" +import { BEHAVIOR_LABELS } from "@/lib/labels" import { ComponentCard } from "./ComponentCard" -const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"] +const BEHAVIOR_ORDER = ["daemon", "tool", "frontend"] interface ComponentGridProps { components: ComponentSummary[] @@ -12,24 +12,24 @@ interface ComponentGridProps { export function ComponentGrid({ components, statuses }: ComponentGridProps) { const statusMap = new Map(statuses.map((s) => [s.id, s])) - // Group by category + // Group by behavior const groups = new Map() for (const comp of components) { - const cat = comp.category - const list = groups.get(cat) ?? [] + const key = comp.behavior ?? "other" + const list = groups.get(key) ?? [] list.push(comp) - groups.set(cat, list) + groups.set(key, list) } return (
- {CATEGORY_ORDER.map((cat) => { - const items = groups.get(cat) + {BEHAVIOR_ORDER.map((key) => { + const items = groups.get(key) if (!items?.length) return null return ( -
+

- {CATEGORY_LABELS[cat] ?? cat} + {BEHAVIOR_LABELS[key] ?? key}

{items.map((comp) => ( diff --git a/app/src/components/ComponentTable.tsx b/app/src/components/ComponentTable.tsx index 1915adb..e0eb2b7 100644 --- a/app/src/components/ComponentTable.tsx +++ b/app/src/components/ComponentTable.tsx @@ -3,26 +3,24 @@ import { Link } from "react-router-dom" import { ArrowDown, ArrowUp, ArrowUpDown, Download, Play, RefreshCw, Square, Trash2 } from "lucide-react" import type { ComponentSummary, HealthStatus } from "@/types" import { useServiceAction, useToolAction } from "@/services/api/hooks" -import { runnerLabel } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" -import { RoleBadge } from "./RoleBadge" +import { BehaviorBadge } from "./BehaviorBadge" +import { StackBadge } from "./StackBadge" interface ComponentTableProps { components: ComponentSummary[] statuses: HealthStatus[] } -type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status" +type SortKey = "id" | "stack" | "behavior" | "status" type SortDir = "asc" | "desc" function statusRank(s: HealthStatus | undefined, installed: boolean | null): number { - // Health takes priority for services if (s) { if (s.status === "down") return 0 if (s.status === "up") return 3 return 2 } - // Installed state for tools if (installed === false) return 1 if (installed === true) return 3 return 2 @@ -30,13 +28,7 @@ function statusRank(s: HealthStatus | undefined, installed: boolean | null): num export function ComponentTable({ components, statuses }: ComponentTableProps) { const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) - const allCategories = useMemo(() => { - const set = new Set() - for (const c of components) set.add(c.category) - return Array.from(set).sort() - }, [components]) - const [categoryFilter, setCategoryFilter] = useState(null) const [search, setSearch] = useState("") const [sortKey, setSortKey] = useState("id") const [sortDir, setSortDir] = useState("asc") @@ -51,20 +43,14 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { } const filtered = useMemo(() => { - let list = components - if (categoryFilter) { - list = list.filter((c) => c.category === categoryFilter) - } - if (search) { - const q = search.toLowerCase() - list = list.filter( - (c) => - c.id.toLowerCase().includes(q) || - (c.description?.toLowerCase().includes(q) ?? false), - ) - } - return list - }, [components, categoryFilter, search]) + if (!search) return components + const q = search.toLowerCase() + return components.filter( + (c) => + c.id.toLowerCase().includes(q) || + (c.description?.toLowerCase().includes(q) ?? false), + ) + }, [components, search]) const sorted = useMemo(() => { const dir = sortDir === "asc" ? 1 : -1 @@ -72,14 +58,10 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { switch (sortKey) { case "id": return dir * a.id.localeCompare(b.id) - case "category": - return dir * a.category.localeCompare(b.category) - case "runner": - return dir * (a.runner ?? "").localeCompare(b.runner ?? "") - case "schedule": - return dir * (a.schedule ?? "").localeCompare(b.schedule ?? "") - case "port": - return dir * ((a.port ?? 0) - (b.port ?? 0)) + case "stack": + return dir * (a.stack ?? "").localeCompare(b.stack ?? "") + case "behavior": + return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "") case "status": return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed)) default: @@ -90,51 +72,22 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { return (
- {/* Filters */} -
+
setSearch(e.target.value)} placeholder="Filter components..." className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56" /> -
- - {allCategories.map((cat) => ( - - ))} -
- {/* Table */}
- - - - + + @@ -149,7 +102,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { ))} {sorted.length === 0 && ( - @@ -222,7 +175,6 @@ function ComponentRow({ {component.id} @@ -233,16 +185,10 @@ function ComponentRow({ )} - - - - - - - - - ) -} diff --git a/app/src/components/RoleBadge.tsx b/app/src/components/RoleBadge.tsx deleted file mode 100644 index d18b22d..0000000 --- a/app/src/components/RoleBadge.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { cn } from "@/lib/utils" -import { CATEGORY_DESCRIPTIONS } from "@/lib/labels" - -const categoryColors: Record = { - service: "bg-green-700 text-white", - job: "bg-purple-700 text-white", - tool: "bg-blue-700 text-white", - frontend: "bg-yellow-600 text-black", - component: "bg-gray-600 text-gray-200", -} - -export function RoleBadge({ role }: { role: string }) { - return ( - - {role} - - ) -} diff --git a/app/src/components/ScheduledSection.tsx b/app/src/components/ScheduledSection.tsx new file mode 100644 index 0000000..f27d887 --- /dev/null +++ b/app/src/components/ScheduledSection.tsx @@ -0,0 +1,107 @@ +import { Link } from "react-router-dom" +import { Play, RefreshCw, Square } from "lucide-react" +import type { ComponentSummary, HealthStatus } from "@/types" +import { useServiceAction } from "@/services/api/hooks" +import { SectionHeader } from "./SectionHeader" +import { StackBadge } from "./StackBadge" + +interface ScheduledSectionProps { + jobs: ComponentSummary[] + statuses: HealthStatus[] +} + +export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) { + const statusMap = new Map(statuses.map((s) => [s.id, s])) + + return ( +
+ +
+
Actions
+ No components match.
- + - {component.runner ? runnerLabel(component.runner) : "—"} - - {component.schedule ?? "—"} - - {component.port ?? "—"} + + {health ? ( diff --git a/app/src/components/JobSection.tsx b/app/src/components/JobSection.tsx deleted file mode 100644 index 0113faf..0000000 --- a/app/src/components/JobSection.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useMemo } from "react" -import { Link } from "react-router-dom" -import { RefreshCw } from "lucide-react" -import type { ComponentSummary } from "@/types" -import { useServiceAction } from "@/services/api/hooks" -import { SectionHeader } from "./SectionHeader" -import { SortHeader, useSort } from "./SortHeader" - -type JobSortKey = "id" | "timer" - -interface JobSectionProps { - jobs: ComponentSummary[] -} - -export function JobSection({ jobs }: JobSectionProps) { - const { sortKey, sortDir, toggleSort } = useSort("id") - - const sorted = useMemo(() => { - const dir = sortDir === "asc" ? 1 : -1 - return [...jobs].sort((a, b) => { - switch (sortKey) { - case "id": - return dir * a.id.localeCompare(b.id) - case "timer": { - const aTimer = a.systemd?.timer ? 1 : 0 - const bTimer = b.systemd?.timer ? 1 : 0 - return dir * (aTimer - bTimer) - } - default: - return 0 - } - }) - }, [jobs, sortKey, sortDir]) - - return ( -
- -
- - - - - - - - - - - {sorted.map((job) => ( - - ))} - -
ScheduleActions
-
-
- ) -} - -function JobRow({ job }: { job: ComponentSummary }) { - const { mutate, isPending } = useServiceAction() - const hasTimer = job.systemd?.timer ?? false - - return ( -
- - {job.id} - - {job.description && ( -

- {job.description} -

- )} -
- {job.schedule ?? "—"} - - {hasTimer ? ( - - - active - - ) : ( - - )} - - {job.managed && ( - - )} -
+ + + + + + + + + + + {jobs.map((job) => ( + + ))} + +
NameScheduleStackStatusActions
+
+
+ ) +} + +function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthStatus }) { + const { mutate, isPending } = useServiceAction() + const isDown = health?.status === "down" + + return ( + + + + {job.id} + + {job.description && ( +

+ {job.description} +

+ )} + + {job.schedule} + + + + + {health ? ( + + {health.status} + + ) : ( + + )} + + +
+ {isDown && ( + + )} + + {!isDown && ( + + )} +
+ + + ) +} diff --git a/app/src/components/SectionHeader.tsx b/app/src/components/SectionHeader.tsx index 8058c5e..2d0203d 100644 --- a/app/src/components/SectionHeader.tsx +++ b/app/src/components/SectionHeader.tsx @@ -1,10 +1,10 @@ import { SECTION_HEADERS } from "@/lib/labels" -export function SectionHeader({ category }: { category: string }) { - const info = SECTION_HEADERS[category] +export function SectionHeader({ section }: { section: string }) { + const info = SECTION_HEADERS[section] return (
-

{info?.title ?? category}

+

{info?.title ?? section}

{info?.subtitle}

) diff --git a/app/src/components/ServiceSection.tsx b/app/src/components/ServiceSection.tsx index e5aa1ce..fcb90b6 100644 --- a/app/src/components/ServiceSection.tsx +++ b/app/src/components/ServiceSection.tsx @@ -13,7 +13,7 @@ export function ServiceSection({ services, statuses }: ServiceSectionProps) { return (
- +
{services.map((svc) => ( diff --git a/app/src/components/StackBadge.tsx b/app/src/components/StackBadge.tsx new file mode 100644 index 0000000..774096c --- /dev/null +++ b/app/src/components/StackBadge.tsx @@ -0,0 +1,11 @@ +import { stackLabel } from "@/lib/labels" + +export function StackBadge({ stack }: { stack: string | null }) { + if (!stack) return null + + return ( + + {stackLabel(stack)} + + ) +} diff --git a/app/src/components/ToolSection.tsx b/app/src/components/ToolSection.tsx deleted file mode 100644 index 830a4f5..0000000 --- a/app/src/components/ToolSection.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useMemo } from "react" -import { Link } from "react-router-dom" -import { Download, Trash2 } from "lucide-react" -import type { ComponentSummary } from "@/types" -import { useToolAction } from "@/services/api/hooks" -import { SectionHeader } from "./SectionHeader" -import { SortHeader, useSort } from "./SortHeader" - -type ToolSortKey = "id" | "status" - -function statusRank(installed: boolean | null): number { - if (installed === false) return 0 - if (installed === true) return 1 - return 2 -} - -interface ToolSectionProps { - tools: ComponentSummary[] -} - -export function ToolSection({ tools }: ToolSectionProps) { - const { sortKey, sortDir, toggleSort } = useSort("id") - - const sorted = useMemo(() => { - const dir = sortDir === "asc" ? 1 : -1 - return [...tools].sort((a, b) => { - switch (sortKey) { - case "id": - return dir * a.id.localeCompare(b.id) - case "status": - return dir * (statusRank(a.installed) - statusRank(b.installed)) - default: - return 0 - } - }) - }, [tools, sortKey, sortDir]) - - return ( -
- -
- - - - - - - - - - - {sorted.map((tool) => ( - - ))} - -
DescriptionActions
-
-
- ) -} - -function ToolRow({ tool }: { tool: ComponentSummary }) { - const { mutate, isPending } = useToolAction() - - return ( - - - - {tool.id} - - - - {tool.description ?? "—"} - - - {tool.installed !== null ? ( - tool.installed ? ( - - - installed - - ) : ( - - - not installed - - ) - ) : ( - - )} - - - {tool.installed !== null && ( - tool.installed ? ( - - ) : ( - - ) - )} - - - ) -} diff --git a/app/src/lib/labels.ts b/app/src/lib/labels.ts index 97944c7..43059bb 100644 --- a/app/src/lib/labels.ts +++ b/app/src/lib/labels.ts @@ -6,30 +6,44 @@ export const RUNNER_LABELS: Record = { remote: "Remote", } -export const CATEGORY_LABELS: Record = { - service: "Services", - job: "Jobs", - tool: "Tools", - frontend: "Frontends", - component: "Components", +export const BEHAVIOR_LABELS: Record = { + daemon: "Daemon", + tool: "Tool", + frontend: "Frontend", } -export const CATEGORY_DESCRIPTIONS: Record = { - service: "Long-running daemon", - job: "Scheduled task", - tool: "CLI utility installed to PATH", - frontend: "Built static assets", - component: "Software component", +export const BEHAVIOR_DESCRIPTIONS: Record = { + daemon: "Long-running process that exposes ports", + tool: "CLI utility or scheduled task", + frontend: "Built web application", +} + +export const STACK_LABELS: Record = { + "python-fastapi": "Python / FastAPI", + "python-cli": "Python / CLI", + "react-vite": "React / Vite", + rust: "Rust", + go: "Go", + bash: "Bash", + container: "Container", + command: "Command", + remote: "Remote", } export const SECTION_HEADERS: Record = { - service: { title: "Services", subtitle: "Long-running daemons managed by systemd" }, - job: { title: "Jobs", subtitle: "Scheduled tasks with cron timers" }, - tool: { title: "Tools", subtitle: "CLI utilities installed to PATH" }, - frontend: { title: "Frontends", subtitle: "Built web applications" }, - component: { title: "Other", subtitle: "Software catalog entries" }, + service: { title: "Services", subtitle: "Long-running processes" }, + scheduled: { title: "Scheduled", subtitle: "Systemd timers" }, + component: { title: "Components", subtitle: "Software catalog" }, } export function runnerLabel(runner: string): string { return RUNNER_LABELS[runner] ?? runner } + +export function behaviorLabel(behavior: string): string { + return BEHAVIOR_LABELS[behavior] ?? behavior +} + +export function stackLabel(stack: string): string { + return STACK_LABELS[stack] ?? stack +} diff --git a/app/src/pages/ComponentDetail.tsx b/app/src/pages/ComponentDetail.tsx index 676df1d..715199c 100644 --- a/app/src/pages/ComponentDetail.tsx +++ b/app/src/pages/ComponentDetail.tsx @@ -8,7 +8,8 @@ import { runnerLabel } from "@/lib/labels" import { ComponentFields } from "@/components/ComponentFields" import { HealthBadge } from "@/components/HealthBadge" import { LogViewer } from "@/components/LogViewer" -import { RoleBadge } from "@/components/RoleBadge" +import { BehaviorBadge } from "@/components/BehaviorBadge" +import { StackBadge } from "@/components/StackBadge" export function ComponentDetailPage() { useEventStream() @@ -20,7 +21,7 @@ export function ComponentDetailPage() { const { mutate, isPending } = useServiceAction() const health = statusResp?.statuses.find((s) => s.id === name) const isDown = health?.status === "down" - const isTool = component?.category === "tool" + const isTool = component?.behavior === "tool" const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "") const isGateway = name === "castle-gateway" const { data: caddyfile } = useCaddyfile(isGateway) @@ -28,8 +29,8 @@ export function ComponentDetailPage() { const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd) const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null) - const configSection = component?.category === "service" ? "services" - : component?.category === "job" ? "jobs" + const configSection = component?.behavior === "daemon" ? "services" + : component?.schedule ? "jobs" : "components" const handleSave = async (compName: string, config: Record) => { @@ -121,7 +122,8 @@ export function ComponentDetailPage() {
- + + {component.source && ( {component.source} )} diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index 002f17d..4ac0673 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -1,14 +1,14 @@ import { useMemo } from "react" -import { Link } from "react-router-dom" import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks" import { GatewayPanel } from "@/components/GatewayPanel" import { MeshPanel } from "@/components/MeshPanel" import { NodeBar } from "@/components/NodeBar" import { ServiceSection } from "@/components/ServiceSection" -import { JobSection } from "@/components/JobSection" -import { ToolSection } from "@/components/ToolSection" +import { ScheduledSection } from "@/components/ScheduledSection" +import { ComponentTable } from "@/components/ComponentTable" import { SectionHeader } from "@/components/SectionHeader" + export function Dashboard() { useEventStream() const { data: components, isLoading } = useComponents() @@ -17,16 +17,10 @@ export function Dashboard() { const { data: nodes } = useNodes() const { data: mesh } = useMeshStatus() - const { services, jobs, tools, frontends, other } = useMemo(() => { - const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components } - for (const c of components ?? []) { - if (c.category === "service") s.services!.push(c) - else if (c.category === "job") s.jobs!.push(c) - else if (c.category === "tool") s.tools!.push(c) - else if (c.category === "frontend") s.frontends!.push(c) - else s.other!.push(c) - } - return { services: s.services!, jobs: s.jobs!, tools: s.tools!, frontends: s.frontends!, other: s.other! } + const { services, scheduled } = useMemo(() => { + const svc = (components ?? []).filter((c) => c.managed && !c.schedule) + const sch = (components ?? []).filter((c) => c.managed && c.schedule) + return { services: svc, scheduled: sch } }, [components]) const statuses = statusResp?.statuses ?? [] @@ -59,62 +53,13 @@ export function Dashboard() { {services.length > 0 && ( )} - {jobs.length > 0 && ( - + {scheduled.length > 0 && ( + )} - {tools.length > 0 && ( - - )} - {frontends.length > 0 && ( + {(components ?? []).length > 0 && (
- -
- - - {frontends.map((fe) => ( - - - - - ))} - -
- - {fe.id} - - - {fe.description ?? "—"} -
-
-
- )} - {other.length > 0 && ( -
- -
- - - {other.map((c) => ( - - - - - ))} - -
- - {c.id} - - - {c.description ?? "—"} -
-
+ +
)}
diff --git a/app/src/pages/NodeDetail.tsx b/app/src/pages/NodeDetail.tsx index a4d2de4..930a600 100644 --- a/app/src/pages/NodeDetail.tsx +++ b/app/src/pages/NodeDetail.tsx @@ -1,7 +1,8 @@ import { useParams, Link } from "react-router-dom" import { ArrowLeft, Server } from "lucide-react" import { useNode } from "@/services/api/hooks" -import { RoleBadge } from "@/components/RoleBadge" +import { BehaviorBadge } from "@/components/BehaviorBadge" +import { StackBadge } from "@/components/StackBadge" import { cn } from "@/lib/utils" export function NodeDetailPage() { @@ -62,7 +63,8 @@ export function NodeDetailPage() { Component - Category + Behavior + Stack Runner Port @@ -85,7 +87,10 @@ export function NodeDetailPage() { )} - + + + + {comp.runner ?? "—"} diff --git a/app/src/types/index.ts b/app/src/types/index.ts index c4c4270..3de3d5a 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -7,7 +7,8 @@ export interface SystemdInfo { export interface ComponentSummary { id: string description: string | null - category: string + behavior: string | null + stack: string | null runner: string | null port: number | null health_path: string | null diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index dae67e0..fb43656 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -16,7 +16,8 @@ class ComponentSummary(BaseModel): id: str description: str | None = None - category: str + behavior: str | None = None + stack: str | None = None runner: str | None = None port: int | None = None health_path: str | None = None diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py index 7191b24..f39179a 100644 --- a/castle-api/src/castle_api/mqtt_client.py +++ b/castle-api/src/castle_api/mqtt_client.py @@ -44,8 +44,10 @@ def _registry_to_json(registry: NodeRegistry) -> str: for name, comp in registry.deployed.items(): entry: dict = { "runner": comp.runner, - "category": comp.category, + "behavior": comp.behavior, } + if comp.stack: + entry["stack"] = comp.stack if comp.description: entry["description"] = comp.description if comp.port is not None: @@ -79,7 +81,8 @@ def _json_to_registry(payload: str) -> NodeRegistry: run_cmd=comp_data.get("run_cmd", []), env=comp_data.get("env", {}), description=comp_data.get("description"), - category=comp_data.get("category", "service"), + behavior=comp_data.get("behavior", "daemon"), + stack=comp_data.get("stack"), port=comp_data.get("port"), health_path=comp_data.get("health_path"), proxy_path=comp_data.get("proxy_path"), diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index cea89a4..21dd164 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -47,7 +47,8 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSum ComponentSummary( id=name, description=d.description, - category=d.category, + behavior=d.behavior, + stack=d.stack, runner=d.runner, port=d.port, health_path=d.health_path, diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 5a57633..752e105 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -44,13 +44,14 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: # Check if tool is installed on PATH installed: bool | None = None - if deployed.category == "tool": + if deployed.behavior == "tool": installed = shutil.which(name) is not None return ComponentSummary( id=name, description=deployed.description, - category=deployed.category, + behavior=deployed.behavior, + stack=deployed.stack, runner=deployed.runner, port=deployed.port, health_path=deployed.health_path, @@ -83,18 +84,21 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon description = svc.description source = None + stack = None if svc.component and svc.component in config.components: comp = config.components[svc.component] if not description: description = comp.description source = comp.source + stack = comp.stack runner = svc.run.runner return ComponentSummary( id=name, description=description, - category="service", + behavior="daemon", + stack=stack, runner=runner, port=port, health_path=health_path, @@ -117,16 +121,19 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma description = job.description source = None + stack = None if job.component and job.component in config.components: comp = config.components[job.component] if not description: description = comp.description source = comp.source + stack = comp.stack return ComponentSummary( id=name, description=description, - category="job", + behavior="tool", + stack=stack, runner=job.run.runner, managed=managed, systemd=systemd_info, @@ -137,16 +144,16 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary: """Build a ComponentSummary from a ComponentSpec (tools/frontends).""" - # Determine category + # Determine behavior is_tool = bool((comp.install and comp.install.path) or comp.tool) is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands)) if is_tool: - category = "tool" + behavior = "tool" elif is_frontend: - category = "frontend" + behavior = "frontend" else: - category = "component" + behavior = None source = comp.source @@ -167,7 +174,8 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo return ComponentSummary( id=name, description=comp.description, - category=category, + behavior=behavior, + stack=comp.stack, runner=runner, version=comp.tool.version if comp.tool else None, source=source, @@ -232,16 +240,19 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]: if ref and ref in config.components: s.source = config.components[ref].source - # Components (tools/frontends) — always listed, even if a - # service/job with the same name exists. A component is - # "what software exists", services/jobs are "how it runs". + # Components (tools/frontends) not already represented by a + # service or job entry. Skip if the component has no + # distinct behavior (e.g. it's just the software identity + # behind a service). for name, comp in config.components.items(): + if name in seen: + continue summary = _summary_from_component(name, comp, root) + if summary.behavior is None: + continue summary.node = local_hostname - # Skip if this exact category is already represented - # (e.g. a deployed tool already in the list) - if not any(s.id == name and s.category == summary.category for s in summaries): - summaries.append(summary) + summaries.append(summary) + seen.add(name) except FileNotFoundError: pass @@ -254,7 +265,8 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]: ComponentSummary( id=name, description=d.description, - category=d.category, + behavior=d.behavior, + stack=d.stack, runner=d.runner, port=d.port, health_path=d.health_path, @@ -306,7 +318,8 @@ def get_component(name: str) -> ComponentDetail: "health_path": deployed.health_path, "proxy_path": deployed.proxy_path, "managed": deployed.managed, - "category": deployed.category, + "behavior": deployed.behavior, + "stack": deployed.stack, } return ComponentDetail(**summary.model_dump(), manifest=raw) diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 1586185..4ba8c1e 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -92,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No "TEST_SVC_DATA_DIR": "/data/castle/test-svc", }, description="Test service", - category="service", + behavior="daemon", port=19000, health_path="/health", proxy_path="/test-svc", diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 4876090..7f1728c 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -34,7 +34,7 @@ class TestComponents: assert svc["health_path"] == "/health" assert svc["proxy_path"] == "/test-svc" assert svc["managed"] is True - assert svc["category"] == "service" + assert svc["behavior"] == "daemon" def test_tool_has_no_port(self, client: TestClient) -> None: """Tool component has no port.""" @@ -42,14 +42,14 @@ class TestComponents: data = response.json() tool = next(c for c in data if c["id"] == "test-tool") assert tool["port"] is None - assert tool["category"] == "tool" + assert tool["behavior"] == "tool" def test_job_has_schedule(self, client: TestClient) -> None: """Job component has schedule.""" response = client.get("/components") data = response.json() job = next(c for c in data if c["id"] == "test-job") - assert job["category"] == "job" + assert job["behavior"] == "tool" assert job["schedule"] == "0 2 * * *" diff --git a/castle-api/tests/test_mqtt.py b/castle-api/tests/test_mqtt.py index 398d492..64f44ea 100644 --- a/castle-api/tests/test_mqtt.py +++ b/castle-api/tests/test_mqtt.py @@ -16,7 +16,8 @@ def _make_registry() -> NodeRegistry: run_cmd=["uv", "run", "my-svc"], env={"PORT": "9001", "SECRET_KEY": "super-secret"}, description="My service", - category="service", + behavior="daemon", + stack="python-fastapi", port=9001, health_path="/health", proxy_path="/my-svc", @@ -25,7 +26,8 @@ def _make_registry() -> NodeRegistry: "my-job": DeployedComponent( runner="command", run_cmd=["my-job"], - category="job", + behavior="tool", + stack="python-cli", schedule="0 2 * * *", ), }, @@ -54,6 +56,8 @@ class TestRegistrySerialization: assert svc.health_path == "/health" assert svc.proxy_path == "/my-svc" assert svc.managed is True + assert svc.behavior == "daemon" + assert svc.stack == "python-fastapi" def test_job_fields_preserved(self) -> None: original = _make_registry() @@ -63,7 +67,8 @@ class TestRegistrySerialization: job = restored.deployed["my-job"] assert job.runner == "command" assert job.schedule == "0 2 * * *" - assert job.category == "job" + assert job.behavior == "tool" + assert job.stack == "python-cli" def test_optional_fields_omitted(self) -> None: """Fields like port, health_path are None when not set.""" diff --git a/castle-api/tests/test_nodes.py b/castle-api/tests/test_nodes.py index 98f3201..2d29435 100644 --- a/castle-api/tests/test_nodes.py +++ b/castle-api/tests/test_nodes.py @@ -45,7 +45,7 @@ class TestNodesList: runner="python", run_cmd=["svc"], port=9050, - category="service", + behavior="daemon", ), }, ) diff --git a/castle.yaml b/castle.yaml index 724b8db..cba82c7 100644 --- a/castle.yaml +++ b/castle.yaml @@ -6,29 +6,35 @@ components: description: Content storage API useful to get context into my data dir from anywhere on the LAN source: components/central-context + stack: python-fastapi notification-bridge: description: Desktop notification forwarder. This taps into your notifications system on the desktop and will forward all notifications the the central context server. source: components/notification-bridge + stack: python-fastapi castle-api: description: Castle API source: castle-api + stack: python-fastapi protonmail: description: ProtonMail email sync via Bridge source: components/protonmail + stack: python-cli install: path: alias: protonmail backup-collect: description: Collect files from various sources into backup directory source: components/backup-collect + stack: python-cli tool: system_dependencies: - rsync castle-app: description: Castle web app source: app + stack: react-vite build: commands: - - pnpm @@ -38,18 +44,21 @@ components: devbox-connect: description: SSH tunnel manager with auto-reconnect source: components/devbox-connect + stack: python-cli install: path: alias: devbox-connect mbox2eml: description: MBOX to EML email converter source: components/mbox2eml + stack: python-cli install: path: alias: mbox2eml android-backup: description: Backup Android device using ADB source: components/android-backup + stack: python-cli install: path: alias: android-backup @@ -59,12 +68,14 @@ components: browser: description: Browse the web using natural language via browser-use source: components/browser + stack: python-cli install: path: alias: browser docx-extractor: description: Extract content and metadata from Word .docx files source: components/docx-extractor + stack: python-cli install: path: alias: docx-extractor @@ -74,6 +85,7 @@ components: docx2md: description: Convert Word .docx files to Markdown source: components/docx2md + stack: python-cli install: path: alias: docx2md @@ -83,18 +95,21 @@ components: gpt: description: OpenAI text generation utility source: components/gpt + stack: python-cli install: path: alias: gpt html2text: description: Convert HTML content to plain text source: components/html2text + stack: python-cli install: path: alias: html2text md2pdf: description: Convert Markdown files to PDF source: components/md2pdf + stack: python-cli install: path: alias: md2pdf @@ -105,18 +120,21 @@ components: mdscraper: description: Combine text files into a single markdown document source: components/mdscraper + stack: python-cli install: path: alias: mdscraper pdf-extractor: description: Extract content and metadata from PDF files source: components/pdf-extractor + stack: python-cli install: path: alias: pdf-extractor pdf2md: description: Convert PDF files to Markdown source: components/pdf2md + stack: python-cli install: path: alias: pdf2md @@ -127,18 +145,21 @@ components: schedule: description: Manage systemd user timers and scheduled tasks source: components/schedule + stack: python-cli install: path: alias: schedule search: description: Manage self-contained searchable collections of files source: components/search + stack: python-cli install: path: alias: search text-extractor: description: Extract content and metadata from text files source: components/text-extractor + stack: python-cli install: path: alias: text-extractor diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 548f16c..f3e111c 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -22,6 +22,13 @@ from castle_cli.manifest import ( ) from castle_cli.templates.scaffold import scaffold_project +# Stack determines default behavior and scaffold template +STACK_DEFAULTS: dict[str, str] = { + "python-fastapi": "daemon", + "python-cli": "tool", + "react-vite": "frontend", +} + def next_available_port(config: object) -> int: """Find the next available port starting from 9001 (9000 is reserved for gateway).""" @@ -42,7 +49,8 @@ def run_create(args: argparse.Namespace) -> int: """Create a new project.""" config = load_config() name = args.name - proj_type = args.type + stack = args.stack + behavior = STACK_DEFAULTS.get(stack) if name in config.components or name in config.services or name in config.jobs: print(f"Error: '{name}' already exists in castle.yaml") @@ -55,9 +63,9 @@ def run_create(args: argparse.Namespace) -> int: print(f"Error: directory 'components/{name}' already exists") return 1 - # Determine port for services + # Determine port for daemons port = args.port - if proj_type == "service" and port is None: + if behavior == "daemon" and port is None: port = next_available_port(config) # Package name: convert kebab-case to snake_case @@ -68,18 +76,19 @@ def run_create(args: argparse.Namespace) -> int: project_dir=project_dir, name=name, package_name=package_name, - proj_type=proj_type, - description=args.description or f"A castle {proj_type}", + stack=stack, + description=args.description or f"A castle {stack} component", port=port, ) # Build entries - if proj_type == "service": + if behavior == "daemon": # Component for software identity config.components[name] = ComponentSpec( id=name, - description=args.description or f"A castle {proj_type}", + description=args.description or f"A castle {stack} component", source=f"components/{name}", + stack=stack, ) # Service for deployment config.services[name] = ServiceSpec( @@ -95,32 +104,34 @@ def run_create(args: argparse.Namespace) -> int: proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")), manage=ManageSpec(systemd=SystemdSpec()), ) - elif proj_type == "tool": + elif behavior == "tool": config.components[name] = ComponentSpec( id=name, - description=args.description or f"A castle {proj_type}", + description=args.description or f"A castle {stack} component", source=f"components/{name}", + stack=stack, tool=ToolSpec(), install=InstallSpec(path=PathInstallSpec(alias=name)), ) else: - # library or other + # frontend or other config.components[name] = ComponentSpec( id=name, - description=args.description or f"A castle {proj_type}", + description=args.description or f"A castle {stack} component", source=f"components/{name}", + stack=stack, ) save_config(config) - print(f"Created {proj_type} '{name}' at {project_dir}") + print(f"Created {stack} component '{name}' at {project_dir}") if port: print(f" Port: {port}") print(" Registered in castle.yaml") print("\nNext steps:") print(f" cd components/{name}") print(" uv sync") - if proj_type == "service": + if behavior == "daemon": print(f" uv run {name} # starts on port {port}") print(f" castle deploy {name} # deploy to ~/.castle/") print(f" castle test {name}") diff --git a/cli/src/castle_cli/commands/deploy.py b/cli/src/castle_cli/commands/deploy.py index b69a2d5..233c165 100644 --- a/cli/src/castle_cli/commands/deploy.py +++ b/cli/src/castle_cli/commands/deploy.py @@ -155,12 +155,18 @@ def _build_deployed_service( if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable: proxy_path = svc.proxy.caddy.path_prefix or f"/{name}" + # Resolve stack from referenced component + stack = None + if svc.component and svc.component in config.components: + stack = config.components[svc.component].stack + return DeployedComponent( runner=run.runner, run_cmd=run_cmd, env=env, description=_resolve_description(config, svc), - category="service", + behavior="daemon", + stack=stack, port=port, health_path=health_path, proxy_path=proxy_path, @@ -189,12 +195,18 @@ def _build_deployed_job( # Build run_cmd run_cmd = _build_run_cmd(run, env) + # Resolve stack from referenced component + stack = None + if job.component and job.component in config.components: + stack = config.components[job.component].stack + return DeployedComponent( runner=run.runner, run_cmd=run_cmd, env=env, description=_resolve_description(config, job), - category="job", + behavior="tool", + stack=stack, schedule=job.schedule, managed=True, ) diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 81db226..8b33589 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -51,18 +51,29 @@ def run_info(args: argparse.Namespace) -> int: print(f"\n{BOLD}{name}{RESET}") print(f"{'─' * 40}") - # Determine category + # Determine behavior if service: - print(f" {BOLD}category{RESET}: service") + print(f" {BOLD}behavior{RESET}: daemon") elif job: - print(f" {BOLD}category{RESET}: job") + print(f" {BOLD}behavior{RESET}: tool") elif component: if component.tool or (component.install and component.install.path): - print(f" {BOLD}category{RESET}: tool") + print(f" {BOLD}behavior{RESET}: tool") elif component.build: - print(f" {BOLD}category{RESET}: frontend") + print(f" {BOLD}behavior{RESET}: frontend") else: - print(f" {BOLD}category{RESET}: component") + print(f" {BOLD}behavior{RESET}: —") + + # Show stack + stack = None + if component and component.stack: + stack = component.stack + elif service and service.component and service.component in config.components: + stack = config.components[service.component].stack + elif job and job.component and job.component in config.components: + stack = config.components[job.component].stack + if stack: + print(f" {BOLD}stack{RESET}: {stack}") # Component info if component: @@ -174,17 +185,26 @@ def _info_json( data["component"] = component.model_dump(exclude_none=True, exclude={"id"}) if service: data["service"] = service.model_dump(exclude_none=True, exclude={"id"}) - data["category"] = "service" + data["behavior"] = "daemon" if job: data["job"] = job.model_dump(exclude_none=True, exclude={"id"}) - data["category"] = "job" + data["behavior"] = "tool" if not service and not job and component: if component.tool or (component.install and component.install.path): - data["category"] = "tool" + data["behavior"] = "tool" elif component.build: - data["category"] = "frontend" - else: - data["category"] = "component" + data["behavior"] = "frontend" + + # Resolve stack + stack = None + if component and component.stack: + stack = component.stack + elif service and service.component and service.component in config.components: + stack = config.components[service.component].stack + elif job and job.component and job.component in config.components: + stack = config.components[job.component].stack + if stack: + data["stack"] = stack if deployed: data["deployed"] = { diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index ecc3268..a94d3bc 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -20,13 +20,23 @@ CYAN = "\033[96m" MAGENTA = "\033[95m" YELLOW = "\033[93m" -CATEGORY_COLORS: dict[str, str] = { - "service": GREEN, - "job": MAGENTA, +BEHAVIOR_COLORS: dict[str, str] = { + "daemon": GREEN, "tool": CYAN, "frontend": YELLOW, } +STACK_DISPLAY: dict[str, str] = { + "python-fastapi": "python-fastapi", + "python-cli": "python-cli", + "react-vite": "react-vite", + "rust": "rust", + "go": "go", + "bash": "bash", + "container": "container", + "command": "command", +} + def _load_deployed() -> dict[str, object] | None: """Try to load deployed state from registry, return None if unavailable.""" @@ -39,26 +49,48 @@ def _load_deployed() -> dict[str, object] | None: return None +def _resolve_stack(config: object, name: str) -> str | None: + """Resolve stack from component reference or direct component.""" + # Check services for component ref + if name in config.services: + svc = config.services[name] + comp_name = svc.component + if comp_name and comp_name in config.components: + return config.components[comp_name].stack + # Check jobs for component ref + if name in config.jobs: + job = config.jobs[name] + comp_name = job.component + if comp_name and comp_name in config.components: + return config.components[comp_name].stack + # Direct component + if name in config.components: + return config.components[name].stack + return None + + def run_list(args: argparse.Namespace) -> int: """List all components, services, and jobs.""" config = load_config() deployed = _load_deployed() - filter_type = getattr(args, "type", None) + filter_behavior = getattr(args, "behavior", None) + filter_stack = getattr(args, "stack", None) if getattr(args, "json", False): - return _list_json(config, deployed, filter_type) + return _list_json(config, deployed, filter_behavior, filter_stack) any_output = False - # Services - if not filter_type or filter_type == "service": - if config.services: + # Daemons (services) + if not filter_behavior or filter_behavior == "daemon": + services = _filter_by_stack(config.services, config, filter_stack) + if services: any_output = True - color = CATEGORY_COLORS["service"] - print(f"\n{BOLD}{color}Services{RESET}") + color = BEHAVIOR_COLORS["daemon"] + print(f"\n{BOLD}{color}Daemons{RESET}") print(f"{color}{'─' * 40}{RESET}") - for name, svc in config.services.items(): + for name, svc in services.items(): port_str = "" if svc.expose and svc.expose.http: port_str = f" :{svc.expose.http.internal.port}" @@ -68,17 +100,20 @@ def run_list(args: argparse.Namespace) -> int: else: status = f"{DIM}?{RESET}" + stack = _resolve_stack(config, name) + stack_str = f" {DIM}{stack}{RESET}" if stack else "" desc = f" {DIM}{svc.description}{RESET}" if svc.description else "" - print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}") + print(f" {status} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}") - # Jobs - if not filter_type or filter_type == "job": - if config.jobs: + # Scheduled (jobs) + if not filter_behavior or filter_behavior == "tool": + jobs = _filter_by_stack(config.jobs, config, filter_stack) + if jobs: any_output = True - color = CATEGORY_COLORS["job"] - print(f"\n{BOLD}{color}Jobs{RESET}") + color = MAGENTA + print(f"\n{BOLD}{color}Scheduled{RESET}") print(f"{color}{'─' * 40}{RESET}") - for name, job in config.jobs.items(): + for name, job in jobs.items(): if deployed is not None: status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}" else: @@ -88,29 +123,37 @@ def run_list(args: argparse.Namespace) -> int: sched = f" {DIM}[{job.schedule}]{RESET}" print(f" {status} {BOLD}{name}{RESET}{sched}{desc}") - # Tools - if not filter_type or filter_type == "tool": - tools = config.tools - if tools: - any_output = True - color = CATEGORY_COLORS["tool"] - print(f"\n{BOLD}{color}Tools{RESET}") - print(f"{color}{'─' * 40}{RESET}") - for name, comp in tools.items(): - desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" - print(f" {BOLD}{name}{RESET}{desc}") + # Components (tools, frontends, etc.) + show_tools = not filter_behavior or filter_behavior == "tool" + show_frontends = not filter_behavior or filter_behavior == "frontend" - # Frontends - if not filter_type or filter_type == "frontend": - frontends = config.frontends - if frontends: + if show_tools or show_frontends: + # Collect non-daemon components + comps: dict[str, tuple[str, str | None, str | None]] = {} + + if show_tools: + for name, comp in config.tools.items(): + if filter_stack and comp.stack != filter_stack: + continue + comps[name] = ("tool", comp.stack, comp.description) + + if show_frontends: + for name, comp in config.frontends.items(): + if filter_stack and comp.stack != filter_stack: + continue + if name not in comps: + comps[name] = ("frontend", comp.stack, comp.description) + + if comps: any_output = True - color = CATEGORY_COLORS["frontend"] - print(f"\n{BOLD}{color}Frontends{RESET}") + color = CYAN + print(f"\n{BOLD}{color}Components{RESET}") print(f"{color}{'─' * 40}{RESET}") - for name, comp in frontends.items(): - desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" - print(f" {BOLD}{name}{RESET}{desc}") + for name, (behavior, stack, description) in comps.items(): + stack_str = f" {DIM}{stack}{RESET}" if stack else "" + behavior_str = f" {behavior}" + desc = f" {DIM}{description}{RESET}" if description else "" + print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}") if not any_output: print("No components found.") @@ -122,47 +165,83 @@ def run_list(args: argparse.Namespace) -> int: return 0 +def _filter_by_stack( + items: dict[str, object], + config: object, + filter_stack: str | None, +) -> dict[str, object]: + """Filter items by stack if a filter is provided.""" + if not filter_stack: + return items + return { + name: item + for name, item in items.items() + if _resolve_stack(config, name) == filter_stack + } + + def _list_json( - config: object, deployed: dict | None, filter_type: str | None + config: object, + deployed: dict | None, + filter_behavior: str | None, + filter_stack: str | None, ) -> int: """Output JSON list of all entries.""" output = [] - if not filter_type or filter_type == "service": + if not filter_behavior or filter_behavior == "daemon": for name, svc in config.services.items(): + stack = _resolve_stack(config, name) + if filter_stack and stack != filter_stack: + continue entry: dict = { "name": name, - "category": "service", + "behavior": "daemon", "deployed": deployed is not None and name in deployed, } + if stack: + entry["stack"] = stack if svc.description: entry["description"] = svc.description if svc.expose and svc.expose.http: entry["port"] = svc.expose.http.internal.port output.append(entry) - if not filter_type or filter_type == "job": + if not filter_behavior or filter_behavior == "tool": for name, job in config.jobs.items(): + stack = _resolve_stack(config, name) + if filter_stack and stack != filter_stack: + continue entry = { "name": name, - "category": "job", + "behavior": "tool", "deployed": deployed is not None and name in deployed, "schedule": job.schedule, } + if stack: + entry["stack"] = stack if job.description: entry["description"] = job.description output.append(entry) - if not filter_type or filter_type == "tool": + if not filter_behavior or filter_behavior == "tool": for name, comp in config.tools.items(): - entry = {"name": name, "category": "tool"} + if filter_stack and comp.stack != filter_stack: + continue + entry = {"name": name, "behavior": "tool"} + if comp.stack: + entry["stack"] = comp.stack if comp.description: entry["description"] = comp.description output.append(entry) - if not filter_type or filter_type == "frontend": + if not filter_behavior or filter_behavior == "frontend": for name, comp in config.frontends.items(): - entry = {"name": name, "category": "frontend"} + if filter_stack and comp.stack != filter_stack: + continue + entry = {"name": name, "behavior": "frontend"} + if comp.stack: + entry["stack"] = comp.stack if comp.description: entry["description"] = comp.description output.append(entry) diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index b07c9a6..128ce8b 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -21,9 +21,13 @@ def build_parser() -> argparse.ArgumentParser: # castle list list_parser = subparsers.add_parser("list", help="List all components") list_parser.add_argument( - "--type", - choices=["service", "job", "tool", "frontend"], - help="Filter by type", + "--behavior", + choices=["daemon", "tool", "frontend"], + help="Filter by behavior", + ) + list_parser.add_argument( + "--stack", + help="Filter by stack (e.g. python-cli, python-fastapi, react-vite)", ) list_parser.add_argument("--json", action="store_true", help="Output as JSON") @@ -31,13 +35,13 @@ def build_parser() -> argparse.ArgumentParser: create_parser = subparsers.add_parser("create", help="Create a new project") create_parser.add_argument("name", help="Project name") create_parser.add_argument( - "--type", - choices=["service", "tool", "library"], + "--stack", + choices=["python-cli", "python-fastapi", "react-vite"], required=True, - help="Project type", + help="Development stack (determines scaffold template and default behavior)", ) create_parser.add_argument("--description", default="", help="Project description") - create_parser.add_argument("--port", type=int, help="Port number (services only)") + create_parser.add_argument("--port", type=int, help="Port number (daemons only)") # castle info info_parser = subparsers.add_parser("info", help="Show component details") info_parser.add_argument("project", help="Component name") diff --git a/cli/src/castle_cli/templates/scaffold.py b/cli/src/castle_cli/templates/scaffold.py index b4279e8..1713439 100644 --- a/cli/src/castle_cli/templates/scaffold.py +++ b/cli/src/castle_cli/templates/scaffold.py @@ -9,19 +9,17 @@ def scaffold_project( project_dir: Path, name: str, package_name: str, - proj_type: str, + stack: str, description: str, port: int | None = None, ) -> None: - """Scaffold a new project from templates.""" - if proj_type == "service": + """Scaffold a new project from templates based on stack.""" + if stack == "python-fastapi": _scaffold_service(project_dir, name, package_name, description, port or 9000) - elif proj_type == "tool": + elif stack == "python-cli": _scaffold_tool(project_dir, name, package_name, description) - elif proj_type == "library": - _scaffold_library(project_dir, name, package_name, description) else: - raise ValueError(f"Unknown project type: {proj_type}") + raise ValueError(f"No scaffold template for stack: {stack}") def _scaffold_service( diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index e6b1155..0c19bc2 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -25,7 +25,7 @@ class TestCreateCommand: args = Namespace( name="my-api", - type="service", + stack="python-fastapi", description="My API service", port=9050, ) @@ -60,7 +60,7 @@ class TestCreateCommand: from castle_cli.commands.create import run_create - args = Namespace(name="my-tool2", type="tool", description="My tool", port=None) + args = Namespace(name="my-tool2", stack="python-cli", description="My tool", port=None) result = run_create(args) assert result == 0 @@ -73,27 +73,6 @@ class TestCreateCommand: assert comp.tool is not None assert comp.install is not None - def test_create_library(self, castle_root: Path) -> None: - """Create a new library project.""" - with ( - patch("castle_cli.commands.create.load_config") as mock_load, - patch("castle_cli.commands.create.save_config"), - ): - config = load_config(castle_root) - mock_load.return_value = config - - from castle_cli.commands.create import run_create - - args = Namespace(name="my-lib", type="library", description="My library", port=None) - result = run_create(args) - - assert result == 0 - project_dir = castle_root / "components" / "my-lib" - assert project_dir.exists() - assert (project_dir / "src" / "my_lib" / "__init__.py").exists() - assert (project_dir / "CLAUDE.md").exists() - assert "my-lib" in config.components - def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: """Creating a project with existing name fails.""" with patch("castle_cli.commands.create.load_config") as mock_load: @@ -105,7 +84,7 @@ class TestCreateCommand: # test-svc exists in the services section args = Namespace( name="test-svc", - type="service", + stack="python-fastapi", description="Duplicate", port=None, ) @@ -126,7 +105,7 @@ class TestCreateCommand: args = Namespace( name="auto-port-svc", - type="service", + stack="python-fastapi", description="Auto port", port=None, ) diff --git a/cli/tests/test_info.py b/cli/tests/test_info.py index 8a6f299..b9d2d7d 100644 --- a/cli/tests/test_info.py +++ b/cli/tests/test_info.py @@ -25,7 +25,7 @@ class TestInfoCommand: assert result == 0 output = capsys.readouterr().out assert "test-svc" in output - assert "service" in output + assert "daemon" in output assert "19000" in output def test_info_tool(self, castle_root: Path, capsys) -> None: @@ -73,7 +73,7 @@ class TestInfoCommand: assert result == 0 output = capsys.readouterr().out data = json.loads(output) - assert data["category"] == "service" + assert data["behavior"] == "daemon" assert data["service"]["expose"]["http"]["internal"]["port"] == 19000 def test_info_shows_env(self, castle_root: Path, capsys) -> None: @@ -91,8 +91,8 @@ class TestInfoCommand: output = capsys.readouterr().out assert "TEST_SVC_DATA_DIR" in output - def test_info_shows_category(self, castle_root: Path, capsys) -> None: - """Info displays category instead of roles.""" + def test_info_shows_behavior(self, castle_root: Path, capsys) -> None: + """Info displays behavior.""" from castle_cli.config import load_config with patch("castle_cli.commands.info.load_config") as mock_load: @@ -104,5 +104,5 @@ class TestInfoCommand: assert result == 0 output = capsys.readouterr().out - assert "category" in output - assert "service" in output + assert "behavior" in output + assert "daemon" in output diff --git a/cli/tests/test_list.py b/cli/tests/test_list.py index c337661..f915334 100644 --- a/cli/tests/test_list.py +++ b/cli/tests/test_list.py @@ -19,7 +19,7 @@ class TestListCommand: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(type=None, json=False) + args = Namespace(behavior=None, stack=None, json=False) result = run_list(args) assert result == 0 @@ -27,13 +27,13 @@ class TestListCommand: assert "test-svc" in captured.out assert "test-tool" in captured.out - def test_list_filter_service(self, castle_root: Path, capsys: object) -> None: - """List filtered to services.""" + def test_list_filter_daemon(self, castle_root: Path, capsys: object) -> None: + """List filtered to daemons.""" with patch("castle_cli.commands.list_cmd.load_config") as mock_load: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(type="service", json=False) + args = Namespace(behavior="daemon", stack=None, json=False) result = run_list(args) assert result == 0 @@ -47,7 +47,7 @@ class TestListCommand: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(type="tool", json=False) + args = Namespace(behavior="tool", stack=None, json=False) result = run_list(args) assert result == 0 @@ -56,12 +56,12 @@ class TestListCommand: assert "test-svc" not in captured.out def test_list_filter_job(self, castle_root: Path, capsys: object) -> None: - """List filtered to jobs.""" + """List filtered to jobs (shown under tool behavior).""" with patch("castle_cli.commands.list_cmd.load_config") as mock_load: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(type="job", json=False) + args = Namespace(behavior="tool", stack=None, json=False) result = run_list(args) assert result == 0 @@ -75,7 +75,7 @@ class TestListCommand: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(type=None, json=True) + args = Namespace(behavior=None, stack=None, json=True) result = run_list(args) assert result == 0 @@ -85,4 +85,4 @@ class TestListCommand: assert "test-svc" in names assert "test-tool" in names svc = next(p for p in data if p["name"] == "test-svc") - assert svc["category"] == "service" + assert svc["behavior"] == "daemon" diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 8e1f4a0..2682425 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -208,6 +208,7 @@ class ComponentSpec(BaseModel): description: str | None = None source: str | None = None + stack: str | None = None install: InstallSpec | None = None tool: ToolSpec | None = None diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index b2074dd..33dfa8f 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -35,7 +35,8 @@ class DeployedComponent: run_cmd: list[str] env: dict[str, str] = field(default_factory=dict) description: str | None = None - category: str = "service" + behavior: str = "daemon" + stack: str | None = None port: int | None = None health_path: str | None = None proxy_path: str | None = None @@ -77,12 +78,18 @@ def load_registry(path: Path | None = None) -> NodeRegistry: deployed: dict[str, DeployedComponent] = {} for name, comp_data in data.get("deployed", {}).items(): + # Support both old "category" and new "behavior" keys for migration + behavior = comp_data.get("behavior") + if behavior is None: + category = comp_data.get("category", "service") + behavior = "daemon" if category == "service" else "tool" if category in ("job", "tool") else "frontend" if category == "frontend" else category deployed[name] = DeployedComponent( runner=comp_data.get("runner", "command"), run_cmd=comp_data.get("run_cmd", []), env=comp_data.get("env", {}), description=comp_data.get("description"), - category=comp_data.get("category", "service"), + behavior=behavior, + stack=comp_data.get("stack"), port=comp_data.get("port"), health_path=comp_data.get("health_path"), proxy_path=comp_data.get("proxy_path"), @@ -120,7 +127,9 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: entry["env"] = comp.env if comp.description: entry["description"] = comp.description - entry["category"] = comp.category + entry["behavior"] = comp.behavior + if comp.stack: + entry["stack"] = comp.stack if comp.port is not None: entry["port"] = comp.port if comp.health_path: diff --git a/docs/component-registry.md b/docs/component-registry.md index 0ab5739..0d16c5a 100644 --- a/docs/component-registry.md +++ b/docs/component-registry.md @@ -118,7 +118,7 @@ Discriminated union on `runner`: |--------|------|--------|------------| | `python` | `uv sync` | `which(tool)` → installed binary | `tool`, `args` | | `command` | *(none)* | `which(argv[0])` → resolved path | `argv` | -| `container` | *(none)* | `podman run` | `image`, `command`, `ports`, `volumes` | +| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` | | `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` | @@ -209,10 +209,10 @@ semantics as services. ```bash # Service — scaffolds project, assigns port, registers in castle.yaml -castle create my-service --type service --description "Does something" +castle create my-service --stack python-fastapi --description "Does something" # Tool — scaffolds under components/ -castle create my-tool --type tool --description "Does something" +castle create my-tool --stack python-cli --description "Does something" ``` ### Manually @@ -256,7 +256,7 @@ services: ### Service lifecycle ```bash -castle create my-service --type service # 1. Scaffold + register +castle create my-service --stack python-fastapi # 1. Scaffold + register cd components/my-service && uv sync # 2. Install deps # ... implement ... castle test my-service # 3. Run tests @@ -276,7 +276,7 @@ castle service disable my-service # Stop and remove systemd unit ### Tool lifecycle ```bash -castle create my-tool --type tool # 1. Scaffold + register +castle create my-tool --stack python-cli # 1. Scaffold + register cd components/my-tool && uv sync # 2. Install deps # ... implement ... castle test my-tool # 3. Run tests diff --git a/docs/design.md b/docs/design.md index 851665b..5177670 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,9 +20,10 @@ is self-sufficient. The mesh is optional. Castle service is just a well-behaved Unix daemon that happens to be registered in a manifest. -3. **Section is category.** Components, services, and jobs live in - separate sections of `castle.yaml`. The section determines the - category — no role derivation needed. +3. **Stack and behavior.** Each component has a *stack* (development + toolchain: python-fastapi, python-cli, react-vite) and a *behavior* + (runtime role: daemon, tool, frontend). Scheduling, systemd management, + and proxying are orthogonal operations — not behaviors. 4. **Language-agnostic above the build line.** Below the build line, every language is different (uv, pnpm, cargo, go). Above it, @@ -210,7 +211,8 @@ deployed: env: CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context CENTRAL_CONTEXT_PORT: "9001" - category: service + behavior: daemon + stack: python-fastapi port: 9001 health_path: /health proxy_path: /central-context @@ -321,32 +323,36 @@ Personal software platform │ /devbox-api devbox-api 9020 devbox ● up │ └─────────────────────────────────────────────────────────┘ -Services · Long-running daemons managed by systemd +┌─────────────────────────────────────────────────────────┐ +│ Mesh ● connected mqtt://localhost:1883 0 peers │ +└─────────────────────────────────────────────────────────┘ + +Daemons · Long-running processes that expose ports ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ castle-api │ │ central-ctx │ │ notif-bridge │ │ ● up 5ms │ │ ● up 12ms │ │ ● up 8ms │ │ :9020 │ │ :9001 │ │ :9002 │ └──────────────┘ └──────────────┘ └──────────────┘ -Jobs · Scheduled tasks with cron timers - Name Schedule Timer - protonmail-sync */5 * * * * active - backup-collect 0 2 * * * active - -Tools · CLI utilities installed to PATH - Name Status - pdf2md ● installed - gpt ● installed +Components · Software catalog + Name Stack Behavior Schedule Status + pdf2md Python / CLI tool — installed + protonmail Python / CLI tool */5 * * * * installed + castle-app React / Vite frontend — — + backup-collect Python / CLI tool 0 2 * * * — ``` **Key components:** - **GatewayPanel** — Route table with live health badges, reload button, collapsible Caddyfile viewer. Node column appears when multi-node. +- **MeshPanel** — MQTT connection status (connected/disconnected badge), + broker address, mDNS status, peer count with links. Hidden when mesh + is disabled. - **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node mode. Each node links to `/node/{hostname}`. -- **ServiceSection** — Service cards in a responsive grid. -- **JobSection** — Sortable table of scheduled tasks. -- **ToolSection** — Sortable table of CLI tools with install/uninstall. +- **ServiceSection** — Daemon cards in a responsive grid. +- **ComponentTable** — Unified sortable table for all non-daemon components + (tools, frontends) with Stack, Behavior, Schedule, and Status columns. **Real-time updates:** - SSE stream at `/stream` pushes `health`, `service-action`, and `mesh` @@ -523,10 +529,15 @@ What exists today: - **Mesh infrastructure** — MQTT client (paho-mqtt), mDNS discovery (python-zeroconf), MeshStateManager, all wired into API lifespan. Opt-in via `CASTLE_API_MQTT_ENABLED` / `CASTLE_API_MDNS_ENABLED`. -- **Node API** — `GET /nodes`, `GET /nodes/{hostname}` endpoints. +- **MQTT broker** — Mosquitto running as `castle-mqtt` Docker container + on port 1883, managed by systemd. Config and data in + `/data/castle/castle-mqtt/`. +- **Node API** — `GET /mesh/status`, `GET /nodes`, `GET /nodes/{hostname}`. `GET /components?include_remote=true` for cross-node component listing. - **Gateway panel** — Dedicated UI showing route table, health per route, reload button, Caddyfile viewer. Cross-node routes shown when multi-node. +- **Mesh panel** — Dashboard UI showing MQTT connection status, broker + address, mDNS state, peer count. Hidden when mesh is disabled. - **Node-aware UI** — NodeBar (hidden single-node), node detail page, mesh SSE events for live node discovery updates. - **Cross-node routing** — Caddyfile generator accepts remote registries, @@ -538,9 +549,8 @@ What doesn't exist yet: support them via `command` runner, but no examples exist yet) - **Build automation** — Castle records build specs but doesn't orchestrate builds (each project builds independently) -- **Mosquitto deployment** — Registered in castle.yaml as `castle-mqtt` - container service, but not yet deployed (needs `castle deploy` + - container runtime) +- **Multi-machine testing** — Mesh infrastructure is built and running + on one node, but not yet tested with a second Castle node ## Technology Map diff --git a/docs/python-tools.md b/docs/stacks/python-cli.md similarity index 99% rename from docs/python-tools.md rename to docs/stacks/python-cli.md index 554ac7d..7efaaaf 100644 --- a/docs/python-tools.md +++ b/docs/stacks/python-cli.md @@ -42,7 +42,7 @@ Examples: `components/pdf2md/`, `components/gpt/`, `components/protonmail/` ## Creating a new tool ```bash -castle create my-tool --type tool --description "Does something" +castle create my-tool --stack python-cli --description "Does something" cd components/my-tool && uv sync ``` diff --git a/docs/web-apis.md b/docs/stacks/python-fastapi.md similarity index 97% rename from docs/web-apis.md rename to docs/stacks/python-fastapi.md index e1f1d01..e841ee5 100644 --- a/docs/web-apis.md +++ b/docs/stacks/python-fastapi.md @@ -433,8 +433,8 @@ uv run ruff format . # Format `castle create` generates all of this automatically: ```bash -castle create my-service --type service --description "Does something useful" +castle create my-service --stack python-fastapi --description "Does something useful" ``` -See @docs/component-registry.md for manifest fields, role derivation, and -the full service lifecycle (enable, logs, gateway reload). +See @docs/component-registry.md for manifest fields, castle.yaml structure, +and the full service lifecycle (enable, logs, gateway reload). diff --git a/docs/web-frontends.md b/docs/stacks/react-vite.md similarity index 97% rename from docs/web-frontends.md rename to docs/stacks/react-vite.md index d675e89..0ee0ab3 100644 --- a/docs/web-frontends.md +++ b/docs/stacks/react-vite.md @@ -116,7 +116,7 @@ handles serving directly from the build output. components: my-frontend: description: Web dashboard - source: my-frontend + source: components/my-frontend build: commands: - ["pnpm", "build"] @@ -124,8 +124,10 @@ components: - dist/ ``` -For production, Caddy serves the static files — add a service entry with a -proxy spec: +For production, Caddy serves the static `dist/` output directly — no +Node process needed. See [Serving with Caddy](#serving-with-caddy) below. + +For development with Vite's dev server, add a service entry: ```yaml services: