refactor: Update component structure to use behavior and stack attributes, enhancing clarity and functionality across services, tools, and frontends

This commit is contained in:
2026-02-23 16:32:55 -08:00
parent 3343e955fd
commit 56b9c8ddf1
45 changed files with 698 additions and 667 deletions

View File

@@ -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 <component> # Show details (--json for machine-readable)
castle create <name> --type service # Scaffold new project
castle create <name> --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/<service-name>/`, 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

View File

@@ -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 |

View File

@@ -0,0 +1,24 @@
import { cn } from "@/lib/utils"
import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels"
const behaviorColors: Record<string, string> = {
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 (
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
behaviorColors[behavior] ?? "bg-gray-600 text-gray-200",
)}
title={BEHAVIOR_DESCRIPTIONS[behavior]}
>
{behaviorLabel(behavior)}
</span>
)
}

View File

@@ -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}
</div>
<div className="flex gap-1 mb-2">
<RoleBadge role={component.category} />
<div className="flex gap-1.5 mb-2">
<BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
</div>
{component.description && (

View File

@@ -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
<span className="text-sm text-[var(--muted)]">{component.description}</span>
</div>
<div className="flex items-center gap-1.5">
<RoleBadge role={component.category} />
<BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
</div>
</button>

View File

@@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
try {
const config: Record<string, unknown> = { ...m }
delete config.id
delete config.category
delete config.behavior
config.description = description || undefined
// Merge plain env + secret references back together

View File

@@ -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<string, ComponentSummary[]>()
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 (
<div className="space-y-8">
{CATEGORY_ORDER.map((cat) => {
const items = groups.get(cat)
{BEHAVIOR_ORDER.map((key) => {
const items = groups.get(key)
if (!items?.length) return null
return (
<section key={cat}>
<section key={key}>
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
{CATEGORY_LABELS[cat] ?? cat}
{BEHAVIOR_LABELS[key] ?? key}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((comp) => (

View File

@@ -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<string>()
for (const c of components) set.add(c.category)
return Array.from(set).sort()
}, [components])
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("id")
const [sortDir, setSortDir] = useState<SortDir>("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 (
<div>
{/* Filters */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="mb-4">
<input
value={search}
onChange={(e) => 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"
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => setCategoryFilter(null)}
className={`text-xs px-2 py-1 rounded transition-colors ${
categoryFilter === null
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
All
</button>
{allCategories.map((cat) => (
<button
key={cat}
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
className={`text-xs px-2 py-1 rounded transition-colors ${
categoryFilter === cat
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Table */}
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Category" sortKey="category" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Schedule" sortKey="schedule" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Port" sortKey="port" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Behavior" sortKey="behavior" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
@@ -149,7 +102,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
))}
{sorted.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center text-[var(--muted)]">
<td colSpan={5} className="px-3 py-6 text-center text-[var(--muted)]">
No components match.
</td>
</tr>
@@ -222,7 +175,6 @@ function ComponentRow({
<Link
to={`/component/${component.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
title={component.systemd?.unit_path ?? undefined}
>
{component.id}
</Link>
@@ -233,16 +185,10 @@ function ComponentRow({
)}
</td>
<td className="px-3 py-2.5">
<RoleBadge role={component.category} />
<StackBadge stack={component.stack} />
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{component.runner ? runnerLabel(component.runner) : "—"}
</td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{component.schedule ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{component.port ?? "—"}
<td className="px-3 py-2.5">
<BehaviorBadge behavior={component.behavior} />
</td>
<td className="px-3 py-2.5">
{health ? (

View File

@@ -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<JobSortKey>("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 (
<section>
<SectionHeader category="job" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
<SortHeader label="Timer" sortKey="timer" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((job) => (
<JobRow key={job.id} job={job} />
))}
</tbody>
</table>
</div>
</section>
)
}
function JobRow({ job }: { job: ComponentSummary }) {
const { mutate, isPending } = useServiceAction()
const hasTimer = job.systemd?.timer ?? false
return (
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${job.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{job.id}
</Link>
{job.description && (
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
{job.description}
</p>
)}
</td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{job.schedule ?? "—"}
</td>
<td className="px-3 py-2.5">
{hasTimer ? (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-purple-900/40 text-purple-400 border border-purple-800/50">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
active
</span>
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{job.managed && (
<button
onClick={() => mutate({ name: job.id, action: "restart" })}
disabled={isPending}
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart"
>
<RefreshCw size={14} />
</button>
)}
</td>
</tr>
)
}

View File

@@ -1,24 +0,0 @@
import { cn } from "@/lib/utils"
import { CATEGORY_DESCRIPTIONS } from "@/lib/labels"
const categoryColors: Record<string, string> = {
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 (
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
categoryColors[role] ?? "bg-gray-600 text-gray-200",
)}
title={CATEGORY_DESCRIPTIONS[role]}
>
{role}
</span>
)
}

View File

@@ -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 (
<section>
<SectionHeader section="scheduled" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<th className="px-3 py-2 font-medium text-[var(--muted)]">Name</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Status</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<ScheduledRow key={job.id} job={job} health={statusMap.get(job.id)} />
))}
</tbody>
</table>
</div>
</section>
)
}
function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthStatus }) {
const { mutate, isPending } = useServiceAction()
const isDown = health?.status === "down"
return (
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${job.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{job.id}
</Link>
{job.description && (
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
{job.description}
</p>
)}
</td>
<td className="px-3 py-2.5 font-mono text-xs">{job.schedule}</td>
<td className="px-3 py-2.5">
<StackBadge stack={job.stack} />
</td>
<td className="px-3 py-2.5">
{health ? (
<span className={`text-xs ${health.status === "up" ? "text-green-400" : "text-red-400"}`}>
{health.status}
</span>
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1">
{isDown && (
<button
onClick={() => mutate({ name: job.id, action: "start" })}
disabled={isPending}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
title="Start"
>
<Play size={14} />
</button>
)}
<button
onClick={() => mutate({ name: job.id, action: "restart" })}
disabled={isPending}
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart"
>
<RefreshCw size={14} />
</button>
{!isDown && (
<button
onClick={() => mutate({ name: job.id, action: "stop" })}
disabled={isPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Stop"
>
<Square size={14} />
</button>
)}
</div>
</td>
</tr>
)
}

View File

@@ -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 (
<div className="mb-3">
<h2 className="text-lg font-semibold">{info?.title ?? category}</h2>
<h2 className="text-lg font-semibold">{info?.title ?? section}</h2>
<p className="text-sm text-[var(--muted)]">{info?.subtitle}</p>
</div>
)

View File

@@ -13,7 +13,7 @@ export function ServiceSection({ services, statuses }: ServiceSectionProps) {
return (
<section>
<SectionHeader category="service" />
<SectionHeader section="service" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((svc) => (
<ComponentCard key={svc.id} component={svc} health={statusMap.get(svc.id)} />

View File

@@ -0,0 +1,11 @@
import { stackLabel } from "@/lib/labels"
export function StackBadge({ stack }: { stack: string | null }) {
if (!stack) return null
return (
<span className="text-[0.65rem] font-mono px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--muted)]">
{stackLabel(stack)}
</span>
)
}

View File

@@ -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<ToolSortKey>("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 (
<section>
<SectionHeader category="tool" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Description</th>
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((tool) => (
<ToolRow key={tool.id} tool={tool} />
))}
</tbody>
</table>
</div>
</section>
)
}
function ToolRow({ tool }: { tool: ComponentSummary }) {
const { mutate, isPending } = useToolAction()
return (
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${tool.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{tool.id}
</Link>
</td>
<td className="px-3 py-2.5 text-[var(--muted)] truncate max-w-xs">
{tool.description ?? "—"}
</td>
<td className="px-3 py-2.5">
{tool.installed !== null ? (
tool.installed ? (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800/50">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
installed
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-zinc-800/40 text-[var(--muted)] border border-[var(--border)]">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-500" />
not installed
</span>
)
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{tool.installed !== null && (
tool.installed ? (
<button
onClick={() => mutate({ name: tool.id, action: "uninstall" })}
disabled={isPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Uninstall from PATH"
>
<Trash2 size={14} />
</button>
) : (
<button
onClick={() => mutate({ name: tool.id, action: "install" })}
disabled={isPending}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
title="Install to PATH"
>
<Download size={14} />
</button>
)
)}
</td>
</tr>
)
}

View File

@@ -6,30 +6,44 @@ export const RUNNER_LABELS: Record<string, string> = {
remote: "Remote",
}
export const CATEGORY_LABELS: Record<string, string> = {
service: "Services",
job: "Jobs",
tool: "Tools",
frontend: "Frontends",
component: "Components",
export const BEHAVIOR_LABELS: Record<string, string> = {
daemon: "Daemon",
tool: "Tool",
frontend: "Frontend",
}
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
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<string, string> = {
daemon: "Long-running process that exposes ports",
tool: "CLI utility or scheduled task",
frontend: "Built web application",
}
export const STACK_LABELS: Record<string, string> = {
"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<string, { title: string; subtitle: string }> = {
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
}

View File

@@ -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<string, unknown>) => {
@@ -121,7 +122,8 @@ export function ComponentDetailPage() {
</div>
<div className="flex items-center gap-3 mb-6">
<RoleBadge role={component.category} />
<BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
{component.source && (
<span className="text-sm text-[var(--muted)] font-mono">{component.source}</span>
)}

View File

@@ -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 && (
<ServiceSection services={services} statuses={statuses} />
)}
{jobs.length > 0 && (
<JobSection jobs={jobs} />
{scheduled.length > 0 && (
<ScheduledSection jobs={scheduled} statuses={statuses} />
)}
{tools.length > 0 && (
<ToolSection tools={tools} />
)}
{frontends.length > 0 && (
{(components ?? []).length > 0 && (
<section>
<SectionHeader category="frontend" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<tbody>
{frontends.map((fe) => (
<tr key={fe.id} className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${fe.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{fe.id}
</Link>
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{fe.description ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{other.length > 0 && (
<section>
<SectionHeader category="component" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<tbody>
{other.map((c) => (
<tr key={c.id} className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${c.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{c.id}
</Link>
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{c.description ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<SectionHeader section="component" />
<ComponentTable components={components ?? []} statuses={statuses} />
</section>
)}
</div>

View File

@@ -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() {
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<th className="px-3 py-2 font-medium text-[var(--muted)]">Component</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Category</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Behavior</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
</tr>
@@ -85,7 +87,10 @@ export function NodeDetailPage() {
)}
</td>
<td className="px-3 py-2.5">
<RoleBadge role={comp.category} />
<BehaviorBadge behavior={comp.behavior} />
</td>
<td className="px-3 py-2.5">
<StackBadge stack={comp.stack} />
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{comp.runner ?? "—"}

View File

@@ -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

View File

@@ -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

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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)

View File

@@ -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",

View File

@@ -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 * * *"

View File

@@ -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."""

View File

@@ -45,7 +45,7 @@ class TestNodesList:
runner="python",
run_cmd=["svc"],
port=9050,
category="service",
behavior="daemon",
),
},
)

View File

@@ -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

View File

@@ -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}")

View File

@@ -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,
)

View File

@@ -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"] = {

View File

@@ -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)

View File

@@ -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")

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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).

View File

@@ -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: