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) - **`services:`** — Long-running daemons (run, expose, proxy, systemd)
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer) - **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
The section determines the category — no role derivation. Services and jobs Each component has a **stack** (development toolchain: python-fastapi,
can reference a component via `component:` for description fallthrough. 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 **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) 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: When creating a new service, tool, or frontend, follow the detailed guides:
- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle - @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
- @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing) - @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing)
- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) - @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns - @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns
### Quick start ### Quick start
```bash ```bash
# Service # Daemon (python-fastapi)
castle create my-service --type service --description "Does something" castle create my-service --stack python-fastapi --description "Does something"
cd components/my-service && uv sync cd components/my-service && uv sync
uv run my-service # starts on auto-assigned port uv run my-service # starts on auto-assigned port
castle service enable my-service # register with systemd castle service enable my-service # register with systemd
castle gateway reload # update reverse proxy routes castle gateway reload # update reverse proxy routes
# Tool # Tool (python-cli)
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 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 ```bash
castle list # List all components 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 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 test [project] # Run tests (one or all)
castle lint [project] # Run linter (one or all) castle lint [project] # Run linter (one or all)
castle sync # Update submodules + uv sync 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` - **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml`
into `~/.castle/generated/Caddyfile`. Dashboard served at root. 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. - **Data**: Service data lives in `/data/castle/<service-name>/`, passed via env var.
- **Secrets**: `~/.castle/secrets/` — never in project directories. - **Secrets**: `~/.castle/secrets/` — never in project directories.
@@ -93,7 +105,8 @@ Gateway:
- `GET /gateway/caddyfile` — Generated Caddyfile content - `GET /gateway/caddyfile` — Generated Caddyfile content
- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy - `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` — All known nodes (local + discovered remote)
- `GET /nodes/{hostname}` — Node detail with deployed components - `GET /nodes/{hostname}` — Node detail with deployed components

View File

@@ -25,22 +25,23 @@ open http://localhost:9000
```bash ```bash
# Service — FastAPI app with health endpoint, systemd unit, gateway route # 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 cd components/my-api && uv sync
castle test my-api castle test my-api
castle service enable my-api castle service enable my-api
castle gateway reload castle gateway reload
# Standalone tool — CLI tool with argparse, stdin/stdout, Unix pipes # 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 ## CLI Reference
``` ```
castle list [--role ROLE] [--json] List all components castle list [--behavior B] [--stack S] [--json] List all components
castle info NAME [--json] Show component details castle info NAME [--json] Show component details
castle create NAME --type TYPE Scaffold a new component 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 run NAME Run component in foreground
castle test [NAME] Run tests (one or all) castle test [NAME] Run tests (one or all)
castle lint [NAME] Run linter (one or all) castle lint [NAME] Run linter (one or all)
@@ -56,7 +57,13 @@ castle tool info NAME Show tool details
## Registry ## 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 ```yaml
gateway: gateway:
@@ -66,6 +73,10 @@ components:
central-context: central-context:
description: Content storage API description: Content storage API
source: components/central-context source: components/central-context
services:
central-context:
component: central-context
run: run:
runner: python runner: python
tool: central-context tool: central-context
@@ -78,22 +89,13 @@ components:
manage: manage:
systemd: {} systemd: {}
notification-bridge: jobs:
description: Desktop notification forwarder backup-collect:
source: components/notification-bridge component: backup-collect
run: run:
runner: python runner: command
tool: notification-bridge argv: [backup-collect]
defaults: schedule: "0 2 * * *"
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
BUCKET_NAME: notifications
expose:
http:
internal: { port: 9002 }
health_path: /health
proxy:
caddy: { path_prefix: /notifications }
manage: manage:
systemd: {} systemd: {}
``` ```
@@ -106,15 +108,16 @@ automatically by `castle deploy`. Only non-convention values need `defaults.env`
``` ```
castle.yaml <- component registry (single source of truth) castle.yaml <- component registry (single source of truth)
cli/ <- castle CLI cli/ <- castle CLI
core/ <- castle-core library (models, config, generators)
castle-api/ <- Castle API (dashboard backend) castle-api/ <- Castle API (dashboard backend)
app/ <- Castle web app (React/Vite frontend) app/ <- Castle web app (React/Vite frontend)
components/ <- all non-infrastructure components components/ <- all non-infrastructure components
central-context/ <- content storage API (git submodule) central-context/ <- content storage API (git submodule)
notification-bridge/ <- desktop notification forwarder (git submodule) notification-bridge/ <- desktop notification forwarder (git submodule)
protonmail/ <- email sync tool/job protonmail/ <- email sync tool/job
devbox-connect/ <- SSH tunnel manager
pdf2md/ <- standalone tool (each tool is its own project) pdf2md/ <- standalone tool (each tool is its own project)
... ...
docs/ <- architecture docs and component guides
ruff.toml <- shared lint config ruff.toml <- shared lint config
pyrightconfig.json <- shared type checking config pyrightconfig.json <- shared type checking config
``` ```
@@ -137,6 +140,7 @@ pyrightconfig.json <- shared type checking config
| central-context | 9001 | Content storage API | | central-context | 9001 | Content storage API |
| notification-bridge | 9002 | Desktop notification forwarder | | notification-bridge | 9002 | Desktop notification forwarder |
| castle-api | 9020 | Castle API (dashboard backend) | | castle-api | 9020 | Castle API (dashboard backend) |
| castle-mqtt | 1883 | MQTT broker for mesh coordination (Mosquitto container) |
### Jobs ### Jobs
@@ -171,3 +175,38 @@ pyrightconfig.json <- shared type checking config
| Component | Description | | Component | Description |
|-----------|-------------| |-----------|-------------|
| castle-app | Castle web dashboard (React/Vite/TypeScript) | | 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 { useServiceAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels" import { runnerLabel } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge" import { HealthBadge } from "./HealthBadge"
import { RoleBadge } from "./RoleBadge" import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
interface ComponentCardProps { interface ComponentCardProps {
component: ComponentSummary component: ComponentSummary
@@ -37,8 +38,9 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
) : null} ) : null}
</div> </div>
<div className="flex gap-1 mb-2"> <div className="flex gap-1.5 mb-2">
<RoleBadge role={component.category} /> <BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
</div> </div>
{component.description && ( {component.description && (

View File

@@ -2,7 +2,8 @@ import { useState } from "react"
import { ChevronDown, ChevronRight } from "lucide-react" import { ChevronDown, ChevronRight } from "lucide-react"
import type { ComponentDetail } from "@/types" import type { ComponentDetail } from "@/types"
import { ComponentFields } from "./ComponentFields" import { ComponentFields } from "./ComponentFields"
import { RoleBadge } from "./RoleBadge" import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
interface ComponentEditorProps { interface ComponentEditorProps {
component: ComponentDetail component: ComponentDetail
@@ -25,7 +26,8 @@ export function ComponentEditor({ component, onSave, onDelete }: ComponentEditor
<span className="text-sm text-[var(--muted)]">{component.description}</span> <span className="text-sm text-[var(--muted)]">{component.description}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<RoleBadge role={component.category} /> <BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
</div> </div>
</button> </button>

View File

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

View File

@@ -1,8 +1,8 @@
import type { ComponentSummary, HealthStatus } from "@/types" import type { ComponentSummary, HealthStatus } from "@/types"
import { CATEGORY_LABELS } from "@/lib/labels" import { BEHAVIOR_LABELS } from "@/lib/labels"
import { ComponentCard } from "./ComponentCard" import { ComponentCard } from "./ComponentCard"
const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"] const BEHAVIOR_ORDER = ["daemon", "tool", "frontend"]
interface ComponentGridProps { interface ComponentGridProps {
components: ComponentSummary[] components: ComponentSummary[]
@@ -12,24 +12,24 @@ interface ComponentGridProps {
export function ComponentGrid({ components, statuses }: ComponentGridProps) { export function ComponentGrid({ components, statuses }: ComponentGridProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s])) const statusMap = new Map(statuses.map((s) => [s.id, s]))
// Group by category // Group by behavior
const groups = new Map<string, ComponentSummary[]>() const groups = new Map<string, ComponentSummary[]>()
for (const comp of components) { for (const comp of components) {
const cat = comp.category const key = comp.behavior ?? "other"
const list = groups.get(cat) ?? [] const list = groups.get(key) ?? []
list.push(comp) list.push(comp)
groups.set(cat, list) groups.set(key, list)
} }
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{CATEGORY_ORDER.map((cat) => { {BEHAVIOR_ORDER.map((key) => {
const items = groups.get(cat) const items = groups.get(key)
if (!items?.length) return null if (!items?.length) return null
return ( return (
<section key={cat}> <section key={key}>
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]"> <h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
{CATEGORY_LABELS[cat] ?? cat} {BEHAVIOR_LABELS[key] ?? key}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((comp) => ( {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 { ArrowDown, ArrowUp, ArrowUpDown, Download, Play, RefreshCw, Square, Trash2 } from "lucide-react"
import type { ComponentSummary, HealthStatus } from "@/types" import type { ComponentSummary, HealthStatus } from "@/types"
import { useServiceAction, useToolAction } from "@/services/api/hooks" import { useServiceAction, useToolAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge" import { HealthBadge } from "./HealthBadge"
import { RoleBadge } from "./RoleBadge" import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
interface ComponentTableProps { interface ComponentTableProps {
components: ComponentSummary[] components: ComponentSummary[]
statuses: HealthStatus[] statuses: HealthStatus[]
} }
type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status" type SortKey = "id" | "stack" | "behavior" | "status"
type SortDir = "asc" | "desc" type SortDir = "asc" | "desc"
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number { function statusRank(s: HealthStatus | undefined, installed: boolean | null): number {
// Health takes priority for services
if (s) { if (s) {
if (s.status === "down") return 0 if (s.status === "down") return 0
if (s.status === "up") return 3 if (s.status === "up") return 3
return 2 return 2
} }
// Installed state for tools
if (installed === false) return 1 if (installed === false) return 1
if (installed === true) return 3 if (installed === true) return 3
return 2 return 2
@@ -30,13 +28,7 @@ function statusRank(s: HealthStatus | undefined, installed: boolean | null): num
export function ComponentTable({ components, statuses }: ComponentTableProps) { export function ComponentTable({ components, statuses }: ComponentTableProps) {
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) 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 [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("id") const [sortKey, setSortKey] = useState<SortKey>("id")
const [sortDir, setSortDir] = useState<SortDir>("asc") const [sortDir, setSortDir] = useState<SortDir>("asc")
@@ -51,20 +43,14 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
} }
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = components if (!search) return components
if (categoryFilter) {
list = list.filter((c) => c.category === categoryFilter)
}
if (search) {
const q = search.toLowerCase() const q = search.toLowerCase()
list = list.filter( return components.filter(
(c) => (c) =>
c.id.toLowerCase().includes(q) || c.id.toLowerCase().includes(q) ||
(c.description?.toLowerCase().includes(q) ?? false), (c.description?.toLowerCase().includes(q) ?? false),
) )
} }, [components, search])
return list
}, [components, categoryFilter, search])
const sorted = useMemo(() => { const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1 const dir = sortDir === "asc" ? 1 : -1
@@ -72,14 +58,10 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
switch (sortKey) { switch (sortKey) {
case "id": case "id":
return dir * a.id.localeCompare(b.id) return dir * a.id.localeCompare(b.id)
case "category": case "stack":
return dir * a.category.localeCompare(b.category) return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
case "runner": case "behavior":
return dir * (a.runner ?? "").localeCompare(b.runner ?? "") return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
case "schedule":
return dir * (a.schedule ?? "").localeCompare(b.schedule ?? "")
case "port":
return dir * ((a.port ?? 0) - (b.port ?? 0))
case "status": case "status":
return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed)) return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed))
default: default:
@@ -90,51 +72,22 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
return ( return (
<div> <div>
{/* Filters */} <div className="mb-4">
<div className="flex items-center gap-3 mb-4 flex-wrap">
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Filter components..." 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" 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> </div>
{/* Table */}
<div className="border border-[var(--border)] rounded-lg overflow-hidden"> <div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left"> <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="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Category" sortKey="category" current={sortKey} dir={sortDir} onSort={toggleSort} /> <SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} /> <SortHeader label="Behavior" sortKey="behavior" 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="Status" sortKey="status" 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> <th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr> </tr>
@@ -149,7 +102,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
))} ))}
{sorted.length === 0 && ( {sorted.length === 0 && (
<tr> <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. No components match.
</td> </td>
</tr> </tr>
@@ -222,7 +175,6 @@ function ComponentRow({
<Link <Link
to={`/component/${component.id}`} to={`/component/${component.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors" className="font-medium hover:text-[var(--primary)] transition-colors"
title={component.systemd?.unit_path ?? undefined}
> >
{component.id} {component.id}
</Link> </Link>
@@ -233,16 +185,10 @@ function ComponentRow({
)} )}
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<RoleBadge role={component.category} /> <StackBadge stack={component.stack} />
</td> </td>
<td className="px-3 py-2.5 text-[var(--muted)]"> <td className="px-3 py-2.5">
{component.runner ? runnerLabel(component.runner) : "—"} <BehaviorBadge behavior={component.behavior} />
</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> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
{health ? ( {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" import { SECTION_HEADERS } from "@/lib/labels"
export function SectionHeader({ category }: { category: string }) { export function SectionHeader({ section }: { section: string }) {
const info = SECTION_HEADERS[category] const info = SECTION_HEADERS[section]
return ( return (
<div className="mb-3"> <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> <p className="text-sm text-[var(--muted)]">{info?.subtitle}</p>
</div> </div>
) )

View File

@@ -13,7 +13,7 @@ export function ServiceSection({ services, statuses }: ServiceSectionProps) {
return ( return (
<section> <section>
<SectionHeader category="service" /> <SectionHeader section="service" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((svc) => ( {services.map((svc) => (
<ComponentCard key={svc.id} component={svc} health={statusMap.get(svc.id)} /> <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", remote: "Remote",
} }
export const CATEGORY_LABELS: Record<string, string> = { export const BEHAVIOR_LABELS: Record<string, string> = {
service: "Services", daemon: "Daemon",
job: "Jobs", tool: "Tool",
tool: "Tools", frontend: "Frontend",
frontend: "Frontends",
component: "Components",
} }
export const CATEGORY_DESCRIPTIONS: Record<string, string> = { export const BEHAVIOR_DESCRIPTIONS: Record<string, string> = {
service: "Long-running daemon", daemon: "Long-running process that exposes ports",
job: "Scheduled task", tool: "CLI utility or scheduled task",
tool: "CLI utility installed to PATH", frontend: "Built web application",
frontend: "Built static assets", }
component: "Software component",
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 }> = { export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
service: { title: "Services", subtitle: "Long-running daemons managed by systemd" }, service: { title: "Services", subtitle: "Long-running processes" },
job: { title: "Jobs", subtitle: "Scheduled tasks with cron timers" }, scheduled: { title: "Scheduled", subtitle: "Systemd timers" },
tool: { title: "Tools", subtitle: "CLI utilities installed to PATH" }, component: { title: "Components", subtitle: "Software catalog" },
frontend: { title: "Frontends", subtitle: "Built web applications" },
component: { title: "Other", subtitle: "Software catalog entries" },
} }
export function runnerLabel(runner: string): string { export function runnerLabel(runner: string): string {
return RUNNER_LABELS[runner] ?? runner 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 { ComponentFields } from "@/components/ComponentFields"
import { HealthBadge } from "@/components/HealthBadge" import { HealthBadge } from "@/components/HealthBadge"
import { LogViewer } from "@/components/LogViewer" import { LogViewer } from "@/components/LogViewer"
import { RoleBadge } from "@/components/RoleBadge" import { BehaviorBadge } from "@/components/BehaviorBadge"
import { StackBadge } from "@/components/StackBadge"
export function ComponentDetailPage() { export function ComponentDetailPage() {
useEventStream() useEventStream()
@@ -20,7 +21,7 @@ export function ComponentDetailPage() {
const { mutate, isPending } = useServiceAction() const { mutate, isPending } = useServiceAction()
const health = statusResp?.statuses.find((s) => s.id === name) const health = statusResp?.statuses.find((s) => s.id === name)
const isDown = health?.status === "down" const isDown = health?.status === "down"
const isTool = component?.category === "tool" const isTool = component?.behavior === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "") const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const isGateway = name === "castle-gateway" const isGateway = name === "castle-gateway"
const { data: caddyfile } = useCaddyfile(isGateway) const { data: caddyfile } = useCaddyfile(isGateway)
@@ -28,8 +29,8 @@ export function ComponentDetailPage() {
const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd) const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null) const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
const configSection = component?.category === "service" ? "services" const configSection = component?.behavior === "daemon" ? "services"
: component?.category === "job" ? "jobs" : component?.schedule ? "jobs"
: "components" : "components"
const handleSave = async (compName: string, config: Record<string, unknown>) => { const handleSave = async (compName: string, config: Record<string, unknown>) => {
@@ -121,7 +122,8 @@ export function ComponentDetailPage() {
</div> </div>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<RoleBadge role={component.category} /> <BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
{component.source && ( {component.source && (
<span className="text-sm text-[var(--muted)] font-mono">{component.source}</span> <span className="text-sm text-[var(--muted)] font-mono">{component.source}</span>
)} )}

View File

@@ -1,14 +1,14 @@
import { useMemo } from "react" import { useMemo } from "react"
import { Link } from "react-router-dom"
import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks" import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
import { GatewayPanel } from "@/components/GatewayPanel" import { GatewayPanel } from "@/components/GatewayPanel"
import { MeshPanel } from "@/components/MeshPanel" import { MeshPanel } from "@/components/MeshPanel"
import { NodeBar } from "@/components/NodeBar" import { NodeBar } from "@/components/NodeBar"
import { ServiceSection } from "@/components/ServiceSection" import { ServiceSection } from "@/components/ServiceSection"
import { JobSection } from "@/components/JobSection" import { ScheduledSection } from "@/components/ScheduledSection"
import { ToolSection } from "@/components/ToolSection" import { ComponentTable } from "@/components/ComponentTable"
import { SectionHeader } from "@/components/SectionHeader" import { SectionHeader } from "@/components/SectionHeader"
export function Dashboard() { export function Dashboard() {
useEventStream() useEventStream()
const { data: components, isLoading } = useComponents() const { data: components, isLoading } = useComponents()
@@ -17,16 +17,10 @@ export function Dashboard() {
const { data: nodes } = useNodes() const { data: nodes } = useNodes()
const { data: mesh } = useMeshStatus() const { data: mesh } = useMeshStatus()
const { services, jobs, tools, frontends, other } = useMemo(() => { const { services, scheduled } = useMemo(() => {
const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components } const svc = (components ?? []).filter((c) => c.managed && !c.schedule)
for (const c of components ?? []) { const sch = (components ?? []).filter((c) => c.managed && c.schedule)
if (c.category === "service") s.services!.push(c) return { services: svc, scheduled: sch }
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! }
}, [components]) }, [components])
const statuses = statusResp?.statuses ?? [] const statuses = statusResp?.statuses ?? []
@@ -59,62 +53,13 @@ export function Dashboard() {
{services.length > 0 && ( {services.length > 0 && (
<ServiceSection services={services} statuses={statuses} /> <ServiceSection services={services} statuses={statuses} />
)} )}
{jobs.length > 0 && ( {scheduled.length > 0 && (
<JobSection jobs={jobs} /> <ScheduledSection jobs={scheduled} statuses={statuses} />
)} )}
{tools.length > 0 && ( {(components ?? []).length > 0 && (
<ToolSection tools={tools} />
)}
{frontends.length > 0 && (
<section> <section>
<SectionHeader category="frontend" /> <SectionHeader section="component" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden"> <ComponentTable components={components ?? []} statuses={statuses} />
<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>
</section> </section>
)} )}
</div> </div>

View File

@@ -1,7 +1,8 @@
import { useParams, Link } from "react-router-dom" import { useParams, Link } from "react-router-dom"
import { ArrowLeft, Server } from "lucide-react" import { ArrowLeft, Server } from "lucide-react"
import { useNode } from "@/services/api/hooks" 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" import { cn } from "@/lib/utils"
export function NodeDetailPage() { export function NodeDetailPage() {
@@ -62,7 +63,8 @@ export function NodeDetailPage() {
<thead> <thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left"> <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)]">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)]">Runner</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th> <th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
</tr> </tr>
@@ -85,7 +87,10 @@ export function NodeDetailPage() {
)} )}
</td> </td>
<td className="px-3 py-2.5"> <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>
<td className="px-3 py-2.5 text-[var(--muted)]"> <td className="px-3 py-2.5 text-[var(--muted)]">
{comp.runner ?? "—"} {comp.runner ?? "—"}

View File

@@ -7,7 +7,8 @@ export interface SystemdInfo {
export interface ComponentSummary { export interface ComponentSummary {
id: string id: string
description: string | null description: string | null
category: string behavior: string | null
stack: string | null
runner: string | null runner: string | null
port: number | null port: number | null
health_path: string | null health_path: string | null

View File

@@ -16,7 +16,8 @@ class ComponentSummary(BaseModel):
id: str id: str
description: str | None = None description: str | None = None
category: str behavior: str | None = None
stack: str | None = None
runner: str | None = None runner: str | None = None
port: int | None = None port: int | None = None
health_path: str | 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(): for name, comp in registry.deployed.items():
entry: dict = { entry: dict = {
"runner": comp.runner, "runner": comp.runner,
"category": comp.category, "behavior": comp.behavior,
} }
if comp.stack:
entry["stack"] = comp.stack
if comp.description: if comp.description:
entry["description"] = comp.description entry["description"] = comp.description
if comp.port is not None: if comp.port is not None:
@@ -79,7 +81,8 @@ def _json_to_registry(payload: str) -> NodeRegistry:
run_cmd=comp_data.get("run_cmd", []), run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}), env=comp_data.get("env", {}),
description=comp_data.get("description"), 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"), port=comp_data.get("port"),
health_path=comp_data.get("health_path"), health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_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( ComponentSummary(
id=name, id=name,
description=d.description, description=d.description,
category=d.category, behavior=d.behavior,
stack=d.stack,
runner=d.runner, runner=d.runner,
port=d.port, port=d.port,
health_path=d.health_path, 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 # Check if tool is installed on PATH
installed: bool | None = None installed: bool | None = None
if deployed.category == "tool": if deployed.behavior == "tool":
installed = shutil.which(name) is not None installed = shutil.which(name) is not None
return ComponentSummary( return ComponentSummary(
id=name, id=name,
description=deployed.description, description=deployed.description,
category=deployed.category, behavior=deployed.behavior,
stack=deployed.stack,
runner=deployed.runner, runner=deployed.runner,
port=deployed.port, port=deployed.port,
health_path=deployed.health_path, health_path=deployed.health_path,
@@ -83,18 +84,21 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
description = svc.description description = svc.description
source = None source = None
stack = None
if svc.component and svc.component in config.components: if svc.component and svc.component in config.components:
comp = config.components[svc.component] comp = config.components[svc.component]
if not description: if not description:
description = comp.description description = comp.description
source = comp.source source = comp.source
stack = comp.stack
runner = svc.run.runner runner = svc.run.runner
return ComponentSummary( return ComponentSummary(
id=name, id=name,
description=description, description=description,
category="service", behavior="daemon",
stack=stack,
runner=runner, runner=runner,
port=port, port=port,
health_path=health_path, health_path=health_path,
@@ -117,16 +121,19 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
description = job.description description = job.description
source = None source = None
stack = None
if job.component and job.component in config.components: if job.component and job.component in config.components:
comp = config.components[job.component] comp = config.components[job.component]
if not description: if not description:
description = comp.description description = comp.description
source = comp.source source = comp.source
stack = comp.stack
return ComponentSummary( return ComponentSummary(
id=name, id=name,
description=description, description=description,
category="job", behavior="tool",
stack=stack,
runner=job.run.runner, runner=job.run.runner,
managed=managed, managed=managed,
systemd=systemd_info, 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: def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ComponentSpec (tools/frontends).""" """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_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands)) is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool: if is_tool:
category = "tool" behavior = "tool"
elif is_frontend: elif is_frontend:
category = "frontend" behavior = "frontend"
else: else:
category = "component" behavior = None
source = comp.source source = comp.source
@@ -167,7 +174,8 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
return ComponentSummary( return ComponentSummary(
id=name, id=name,
description=comp.description, description=comp.description,
category=category, behavior=behavior,
stack=comp.stack,
runner=runner, runner=runner,
version=comp.tool.version if comp.tool else None, version=comp.tool.version if comp.tool else None,
source=source, source=source,
@@ -232,16 +240,19 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
if ref and ref in config.components: if ref and ref in config.components:
s.source = config.components[ref].source s.source = config.components[ref].source
# Components (tools/frontends) — always listed, even if a # Components (tools/frontends) not already represented by a
# service/job with the same name exists. A component is # service or job entry. Skip if the component has no
# "what software exists", services/jobs are "how it runs". # distinct behavior (e.g. it's just the software identity
# behind a service).
for name, comp in config.components.items(): for name, comp in config.components.items():
if name in seen:
continue
summary = _summary_from_component(name, comp, root) summary = _summary_from_component(name, comp, root)
if summary.behavior is None:
continue
summary.node = local_hostname 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: except FileNotFoundError:
pass pass
@@ -254,7 +265,8 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
ComponentSummary( ComponentSummary(
id=name, id=name,
description=d.description, description=d.description,
category=d.category, behavior=d.behavior,
stack=d.stack,
runner=d.runner, runner=d.runner,
port=d.port, port=d.port,
health_path=d.health_path, health_path=d.health_path,
@@ -306,7 +318,8 @@ def get_component(name: str) -> ComponentDetail:
"health_path": deployed.health_path, "health_path": deployed.health_path,
"proxy_path": deployed.proxy_path, "proxy_path": deployed.proxy_path,
"managed": deployed.managed, "managed": deployed.managed,
"category": deployed.category, "behavior": deployed.behavior,
"stack": deployed.stack,
} }
return ComponentDetail(**summary.model_dump(), manifest=raw) 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", "TEST_SVC_DATA_DIR": "/data/castle/test-svc",
}, },
description="Test service", description="Test service",
category="service", behavior="daemon",
port=19000, port=19000,
health_path="/health", health_path="/health",
proxy_path="/test-svc", proxy_path="/test-svc",

View File

@@ -34,7 +34,7 @@ class TestComponents:
assert svc["health_path"] == "/health" assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc" assert svc["proxy_path"] == "/test-svc"
assert svc["managed"] is True assert svc["managed"] is True
assert svc["category"] == "service" assert svc["behavior"] == "daemon"
def test_tool_has_no_port(self, client: TestClient) -> None: def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port.""" """Tool component has no port."""
@@ -42,14 +42,14 @@ class TestComponents:
data = response.json() data = response.json()
tool = next(c for c in data if c["id"] == "test-tool") tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None assert tool["port"] is None
assert tool["category"] == "tool" assert tool["behavior"] == "tool"
def test_job_has_schedule(self, client: TestClient) -> None: def test_job_has_schedule(self, client: TestClient) -> None:
"""Job component has schedule.""" """Job component has schedule."""
response = client.get("/components") response = client.get("/components")
data = response.json() data = response.json()
job = next(c for c in data if c["id"] == "test-job") 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 * * *" assert job["schedule"] == "0 2 * * *"

View File

@@ -16,7 +16,8 @@ def _make_registry() -> NodeRegistry:
run_cmd=["uv", "run", "my-svc"], run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"}, env={"PORT": "9001", "SECRET_KEY": "super-secret"},
description="My service", description="My service",
category="service", behavior="daemon",
stack="python-fastapi",
port=9001, port=9001,
health_path="/health", health_path="/health",
proxy_path="/my-svc", proxy_path="/my-svc",
@@ -25,7 +26,8 @@ def _make_registry() -> NodeRegistry:
"my-job": DeployedComponent( "my-job": DeployedComponent(
runner="command", runner="command",
run_cmd=["my-job"], run_cmd=["my-job"],
category="job", behavior="tool",
stack="python-cli",
schedule="0 2 * * *", schedule="0 2 * * *",
), ),
}, },
@@ -54,6 +56,8 @@ class TestRegistrySerialization:
assert svc.health_path == "/health" assert svc.health_path == "/health"
assert svc.proxy_path == "/my-svc" assert svc.proxy_path == "/my-svc"
assert svc.managed is True assert svc.managed is True
assert svc.behavior == "daemon"
assert svc.stack == "python-fastapi"
def test_job_fields_preserved(self) -> None: def test_job_fields_preserved(self) -> None:
original = _make_registry() original = _make_registry()
@@ -63,7 +67,8 @@ class TestRegistrySerialization:
job = restored.deployed["my-job"] job = restored.deployed["my-job"]
assert job.runner == "command" assert job.runner == "command"
assert job.schedule == "0 2 * * *" 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: def test_optional_fields_omitted(self) -> None:
"""Fields like port, health_path are None when not set.""" """Fields like port, health_path are None when not set."""

View File

@@ -45,7 +45,7 @@ class TestNodesList:
runner="python", runner="python",
run_cmd=["svc"], run_cmd=["svc"],
port=9050, 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 description: Content storage API useful to get context into my data dir from anywhere
on the LAN on the LAN
source: components/central-context source: components/central-context
stack: python-fastapi
notification-bridge: notification-bridge:
description: Desktop notification forwarder. This taps into your notifications description: Desktop notification forwarder. This taps into your notifications
system on the desktop and will forward all notifications the the central context system on the desktop and will forward all notifications the the central context
server. server.
source: components/notification-bridge source: components/notification-bridge
stack: python-fastapi
castle-api: castle-api:
description: Castle API description: Castle API
source: castle-api source: castle-api
stack: python-fastapi
protonmail: protonmail:
description: ProtonMail email sync via Bridge description: ProtonMail email sync via Bridge
source: components/protonmail source: components/protonmail
stack: python-cli
install: install:
path: path:
alias: protonmail alias: protonmail
backup-collect: backup-collect:
description: Collect files from various sources into backup directory description: Collect files from various sources into backup directory
source: components/backup-collect source: components/backup-collect
stack: python-cli
tool: tool:
system_dependencies: system_dependencies:
- rsync - rsync
castle-app: castle-app:
description: Castle web app description: Castle web app
source: app source: app
stack: react-vite
build: build:
commands: commands:
- - pnpm - - pnpm
@@ -38,18 +44,21 @@ components:
devbox-connect: devbox-connect:
description: SSH tunnel manager with auto-reconnect description: SSH tunnel manager with auto-reconnect
source: components/devbox-connect source: components/devbox-connect
stack: python-cli
install: install:
path: path:
alias: devbox-connect alias: devbox-connect
mbox2eml: mbox2eml:
description: MBOX to EML email converter description: MBOX to EML email converter
source: components/mbox2eml source: components/mbox2eml
stack: python-cli
install: install:
path: path:
alias: mbox2eml alias: mbox2eml
android-backup: android-backup:
description: Backup Android device using ADB description: Backup Android device using ADB
source: components/android-backup source: components/android-backup
stack: python-cli
install: install:
path: path:
alias: android-backup alias: android-backup
@@ -59,12 +68,14 @@ components:
browser: browser:
description: Browse the web using natural language via browser-use description: Browse the web using natural language via browser-use
source: components/browser source: components/browser
stack: python-cli
install: install:
path: path:
alias: browser alias: browser
docx-extractor: docx-extractor:
description: Extract content and metadata from Word .docx files description: Extract content and metadata from Word .docx files
source: components/docx-extractor source: components/docx-extractor
stack: python-cli
install: install:
path: path:
alias: docx-extractor alias: docx-extractor
@@ -74,6 +85,7 @@ components:
docx2md: docx2md:
description: Convert Word .docx files to Markdown description: Convert Word .docx files to Markdown
source: components/docx2md source: components/docx2md
stack: python-cli
install: install:
path: path:
alias: docx2md alias: docx2md
@@ -83,18 +95,21 @@ components:
gpt: gpt:
description: OpenAI text generation utility description: OpenAI text generation utility
source: components/gpt source: components/gpt
stack: python-cli
install: install:
path: path:
alias: gpt alias: gpt
html2text: html2text:
description: Convert HTML content to plain text description: Convert HTML content to plain text
source: components/html2text source: components/html2text
stack: python-cli
install: install:
path: path:
alias: html2text alias: html2text
md2pdf: md2pdf:
description: Convert Markdown files to PDF description: Convert Markdown files to PDF
source: components/md2pdf source: components/md2pdf
stack: python-cli
install: install:
path: path:
alias: md2pdf alias: md2pdf
@@ -105,18 +120,21 @@ components:
mdscraper: mdscraper:
description: Combine text files into a single markdown document description: Combine text files into a single markdown document
source: components/mdscraper source: components/mdscraper
stack: python-cli
install: install:
path: path:
alias: mdscraper alias: mdscraper
pdf-extractor: pdf-extractor:
description: Extract content and metadata from PDF files description: Extract content and metadata from PDF files
source: components/pdf-extractor source: components/pdf-extractor
stack: python-cli
install: install:
path: path:
alias: pdf-extractor alias: pdf-extractor
pdf2md: pdf2md:
description: Convert PDF files to Markdown description: Convert PDF files to Markdown
source: components/pdf2md source: components/pdf2md
stack: python-cli
install: install:
path: path:
alias: pdf2md alias: pdf2md
@@ -127,18 +145,21 @@ components:
schedule: schedule:
description: Manage systemd user timers and scheduled tasks description: Manage systemd user timers and scheduled tasks
source: components/schedule source: components/schedule
stack: python-cli
install: install:
path: path:
alias: schedule alias: schedule
search: search:
description: Manage self-contained searchable collections of files description: Manage self-contained searchable collections of files
source: components/search source: components/search
stack: python-cli
install: install:
path: path:
alias: search alias: search
text-extractor: text-extractor:
description: Extract content and metadata from text files description: Extract content and metadata from text files
source: components/text-extractor source: components/text-extractor
stack: python-cli
install: install:
path: path:
alias: text-extractor alias: text-extractor

View File

@@ -22,6 +22,13 @@ from castle_cli.manifest import (
) )
from castle_cli.templates.scaffold import scaffold_project 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: def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway).""" """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.""" """Create a new project."""
config = load_config() config = load_config()
name = args.name 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: if name in config.components or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml") 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") print(f"Error: directory 'components/{name}' already exists")
return 1 return 1
# Determine port for services # Determine port for daemons
port = args.port port = args.port
if proj_type == "service" and port is None: if behavior == "daemon" and port is None:
port = next_available_port(config) port = next_available_port(config)
# Package name: convert kebab-case to snake_case # Package name: convert kebab-case to snake_case
@@ -68,18 +76,19 @@ def run_create(args: argparse.Namespace) -> int:
project_dir=project_dir, project_dir=project_dir,
name=name, name=name,
package_name=package_name, package_name=package_name,
proj_type=proj_type, stack=stack,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {stack} component",
port=port, port=port,
) )
# Build entries # Build entries
if proj_type == "service": if behavior == "daemon":
# Component for software identity # Component for software identity
config.components[name] = ComponentSpec( config.components[name] = ComponentSpec(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {stack} component",
source=f"components/{name}", source=f"components/{name}",
stack=stack,
) )
# Service for deployment # Service for deployment
config.services[name] = ServiceSpec( config.services[name] = ServiceSpec(
@@ -95,32 +104,34 @@ def run_create(args: argparse.Namespace) -> int:
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")), proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
elif proj_type == "tool": elif behavior == "tool":
config.components[name] = ComponentSpec( config.components[name] = ComponentSpec(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {stack} component",
source=f"components/{name}", source=f"components/{name}",
stack=stack,
tool=ToolSpec(), tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias=name)), install=InstallSpec(path=PathInstallSpec(alias=name)),
) )
else: else:
# library or other # frontend or other
config.components[name] = ComponentSpec( config.components[name] = ComponentSpec(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {stack} component",
source=f"components/{name}", source=f"components/{name}",
stack=stack,
) )
save_config(config) save_config(config)
print(f"Created {proj_type} '{name}' at {project_dir}") print(f"Created {stack} component '{name}' at {project_dir}")
if port: if port:
print(f" Port: {port}") print(f" Port: {port}")
print(" Registered in castle.yaml") print(" Registered in castle.yaml")
print("\nNext steps:") print("\nNext steps:")
print(f" cd components/{name}") print(f" cd components/{name}")
print(" uv sync") print(" uv sync")
if proj_type == "service": if behavior == "daemon":
print(f" uv run {name} # starts on port {port}") print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name} # deploy to ~/.castle/") print(f" castle deploy {name} # deploy to ~/.castle/")
print(f" castle test {name}") 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: if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}" 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( return DeployedComponent(
runner=run.runner, runner=run.runner,
run_cmd=run_cmd, run_cmd=run_cmd,
env=env, env=env,
description=_resolve_description(config, svc), description=_resolve_description(config, svc),
category="service", behavior="daemon",
stack=stack,
port=port, port=port,
health_path=health_path, health_path=health_path,
proxy_path=proxy_path, proxy_path=proxy_path,
@@ -189,12 +195,18 @@ def _build_deployed_job(
# Build run_cmd # Build run_cmd
run_cmd = _build_run_cmd(run, env) 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( return DeployedComponent(
runner=run.runner, runner=run.runner,
run_cmd=run_cmd, run_cmd=run_cmd,
env=env, env=env,
description=_resolve_description(config, job), description=_resolve_description(config, job),
category="job", behavior="tool",
stack=stack,
schedule=job.schedule, schedule=job.schedule,
managed=True, managed=True,
) )

View File

@@ -51,18 +51,29 @@ def run_info(args: argparse.Namespace) -> int:
print(f"\n{BOLD}{name}{RESET}") print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}") print(f"{'' * 40}")
# Determine category # Determine behavior
if service: if service:
print(f" {BOLD}category{RESET}: service") print(f" {BOLD}behavior{RESET}: daemon")
elif job: elif job:
print(f" {BOLD}category{RESET}: job") print(f" {BOLD}behavior{RESET}: tool")
elif component: elif component:
if component.tool or (component.install and component.install.path): 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: elif component.build:
print(f" {BOLD}category{RESET}: frontend") print(f" {BOLD}behavior{RESET}: frontend")
else: 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 # Component info
if component: if component:
@@ -174,17 +185,26 @@ def _info_json(
data["component"] = component.model_dump(exclude_none=True, exclude={"id"}) data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
if service: if service:
data["service"] = service.model_dump(exclude_none=True, exclude={"id"}) data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
data["category"] = "service" data["behavior"] = "daemon"
if job: if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"}) 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 not service and not job and component:
if component.tool or (component.install and component.install.path): if component.tool or (component.install and component.install.path):
data["category"] = "tool" data["behavior"] = "tool"
elif component.build: elif component.build:
data["category"] = "frontend" data["behavior"] = "frontend"
else:
data["category"] = "component" # 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: if deployed:
data["deployed"] = { data["deployed"] = {

View File

@@ -20,13 +20,23 @@ CYAN = "\033[96m"
MAGENTA = "\033[95m" MAGENTA = "\033[95m"
YELLOW = "\033[93m" YELLOW = "\033[93m"
CATEGORY_COLORS: dict[str, str] = { BEHAVIOR_COLORS: dict[str, str] = {
"service": GREEN, "daemon": GREEN,
"job": MAGENTA,
"tool": CYAN, "tool": CYAN,
"frontend": YELLOW, "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: def _load_deployed() -> dict[str, object] | None:
"""Try to load deployed state from registry, return None if unavailable.""" """Try to load deployed state from registry, return None if unavailable."""
@@ -39,26 +49,48 @@ def _load_deployed() -> dict[str, object] | None:
return 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: def run_list(args: argparse.Namespace) -> int:
"""List all components, services, and jobs.""" """List all components, services, and jobs."""
config = load_config() config = load_config()
deployed = _load_deployed() 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): if getattr(args, "json", False):
return _list_json(config, deployed, filter_type) return _list_json(config, deployed, filter_behavior, filter_stack)
any_output = False any_output = False
# Services # Daemons (services)
if not filter_type or filter_type == "service": if not filter_behavior or filter_behavior == "daemon":
if config.services: services = _filter_by_stack(config.services, config, filter_stack)
if services:
any_output = True any_output = True
color = CATEGORY_COLORS["service"] color = BEHAVIOR_COLORS["daemon"]
print(f"\n{BOLD}{color}Services{RESET}") print(f"\n{BOLD}{color}Daemons{RESET}")
print(f"{color}{'' * 40}{RESET}") print(f"{color}{'' * 40}{RESET}")
for name, svc in config.services.items(): for name, svc in services.items():
port_str = "" port_str = ""
if svc.expose and svc.expose.http: if svc.expose and svc.expose.http:
port_str = f" :{svc.expose.http.internal.port}" port_str = f" :{svc.expose.http.internal.port}"
@@ -68,17 +100,20 @@ def run_list(args: argparse.Namespace) -> int:
else: else:
status = f"{DIM}?{RESET}" 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 "" 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 # Scheduled (jobs)
if not filter_type or filter_type == "job": if not filter_behavior or filter_behavior == "tool":
if config.jobs: jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs:
any_output = True any_output = True
color = CATEGORY_COLORS["job"] color = MAGENTA
print(f"\n{BOLD}{color}Jobs{RESET}") print(f"\n{BOLD}{color}Scheduled{RESET}")
print(f"{color}{'' * 40}{RESET}") print(f"{color}{'' * 40}{RESET}")
for name, job in config.jobs.items(): for name, job in jobs.items():
if deployed is not None: if deployed is not None:
status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}" status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}"
else: else:
@@ -88,29 +123,37 @@ def run_list(args: argparse.Namespace) -> int:
sched = f" {DIM}[{job.schedule}]{RESET}" sched = f" {DIM}[{job.schedule}]{RESET}"
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}") print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
# Tools # Components (tools, frontends, etc.)
if not filter_type or filter_type == "tool": show_tools = not filter_behavior or filter_behavior == "tool"
tools = config.tools show_frontends = not filter_behavior or filter_behavior == "frontend"
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}")
# Frontends if show_tools or show_frontends:
if not filter_type or filter_type == "frontend": # Collect non-daemon components
frontends = config.frontends comps: dict[str, tuple[str, str | None, str | None]] = {}
if frontends:
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 any_output = True
color = CATEGORY_COLORS["frontend"] color = CYAN
print(f"\n{BOLD}{color}Frontends{RESET}") print(f"\n{BOLD}{color}Components{RESET}")
print(f"{color}{'' * 40}{RESET}") print(f"{color}{'' * 40}{RESET}")
for name, comp in frontends.items(): for name, (behavior, stack, description) in comps.items():
desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" stack_str = f" {DIM}{stack}{RESET}" if stack else ""
print(f" {BOLD}{name}{RESET}{desc}") 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: if not any_output:
print("No components found.") print("No components found.")
@@ -122,47 +165,83 @@ def run_list(args: argparse.Namespace) -> int:
return 0 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( 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: ) -> int:
"""Output JSON list of all entries.""" """Output JSON list of all entries."""
output = [] output = []
if not filter_type or filter_type == "service": if not filter_behavior or filter_behavior == "daemon":
for name, svc in config.services.items(): for name, svc in config.services.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry: dict = { entry: dict = {
"name": name, "name": name,
"category": "service", "behavior": "daemon",
"deployed": deployed is not None and name in deployed, "deployed": deployed is not None and name in deployed,
} }
if stack:
entry["stack"] = stack
if svc.description: if svc.description:
entry["description"] = svc.description entry["description"] = svc.description
if svc.expose and svc.expose.http: if svc.expose and svc.expose.http:
entry["port"] = svc.expose.http.internal.port entry["port"] = svc.expose.http.internal.port
output.append(entry) 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(): for name, job in config.jobs.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = { entry = {
"name": name, "name": name,
"category": "job", "behavior": "tool",
"deployed": deployed is not None and name in deployed, "deployed": deployed is not None and name in deployed,
"schedule": job.schedule, "schedule": job.schedule,
} }
if stack:
entry["stack"] = stack
if job.description: if job.description:
entry["description"] = job.description entry["description"] = job.description
output.append(entry) 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(): 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: if comp.description:
entry["description"] = comp.description entry["description"] = comp.description
output.append(entry) 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(): 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: if comp.description:
entry["description"] = comp.description entry["description"] = comp.description
output.append(entry) output.append(entry)

View File

@@ -21,9 +21,13 @@ def build_parser() -> argparse.ArgumentParser:
# castle list # castle list
list_parser = subparsers.add_parser("list", help="List all components") list_parser = subparsers.add_parser("list", help="List all components")
list_parser.add_argument( list_parser.add_argument(
"--type", "--behavior",
choices=["service", "job", "tool", "frontend"], choices=["daemon", "tool", "frontend"],
help="Filter by type", 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") 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 = subparsers.add_parser("create", help="Create a new project")
create_parser.add_argument("name", help="Project name") create_parser.add_argument("name", help="Project name")
create_parser.add_argument( create_parser.add_argument(
"--type", "--stack",
choices=["service", "tool", "library"], choices=["python-cli", "python-fastapi", "react-vite"],
required=True, 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("--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 # castle info
info_parser = subparsers.add_parser("info", help="Show component details") info_parser = subparsers.add_parser("info", help="Show component details")
info_parser.add_argument("project", help="Component name") info_parser.add_argument("project", help="Component name")

View File

@@ -9,19 +9,17 @@ def scaffold_project(
project_dir: Path, project_dir: Path,
name: str, name: str,
package_name: str, package_name: str,
proj_type: str, stack: str,
description: str, description: str,
port: int | None = None, port: int | None = None,
) -> None: ) -> None:
"""Scaffold a new project from templates.""" """Scaffold a new project from templates based on stack."""
if proj_type == "service": if stack == "python-fastapi":
_scaffold_service(project_dir, name, package_name, description, port or 9000) _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) _scaffold_tool(project_dir, name, package_name, description)
elif proj_type == "library":
_scaffold_library(project_dir, name, package_name, description)
else: else:
raise ValueError(f"Unknown project type: {proj_type}") raise ValueError(f"No scaffold template for stack: {stack}")
def _scaffold_service( def _scaffold_service(

View File

@@ -25,7 +25,7 @@ class TestCreateCommand:
args = Namespace( args = Namespace(
name="my-api", name="my-api",
type="service", stack="python-fastapi",
description="My API service", description="My API service",
port=9050, port=9050,
) )
@@ -60,7 +60,7 @@ class TestCreateCommand:
from castle_cli.commands.create import run_create 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) result = run_create(args)
assert result == 0 assert result == 0
@@ -73,27 +73,6 @@ class TestCreateCommand:
assert comp.tool is not None assert comp.tool is not None
assert comp.install 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: def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
"""Creating a project with existing name fails.""" """Creating a project with existing name fails."""
with patch("castle_cli.commands.create.load_config") as mock_load: with patch("castle_cli.commands.create.load_config") as mock_load:
@@ -105,7 +84,7 @@ class TestCreateCommand:
# test-svc exists in the services section # test-svc exists in the services section
args = Namespace( args = Namespace(
name="test-svc", name="test-svc",
type="service", stack="python-fastapi",
description="Duplicate", description="Duplicate",
port=None, port=None,
) )
@@ -126,7 +105,7 @@ class TestCreateCommand:
args = Namespace( args = Namespace(
name="auto-port-svc", name="auto-port-svc",
type="service", stack="python-fastapi",
description="Auto port", description="Auto port",
port=None, port=None,
) )

View File

@@ -25,7 +25,7 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "test-svc" in output assert "test-svc" in output
assert "service" in output assert "daemon" in output
assert "19000" in output assert "19000" in output
def test_info_tool(self, castle_root: Path, capsys) -> None: def test_info_tool(self, castle_root: Path, capsys) -> None:
@@ -73,7 +73,7 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
data = json.loads(output) data = json.loads(output)
assert data["category"] == "service" assert data["behavior"] == "daemon"
assert data["service"]["expose"]["http"]["internal"]["port"] == 19000 assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
def test_info_shows_env(self, castle_root: Path, capsys) -> None: def test_info_shows_env(self, castle_root: Path, capsys) -> None:
@@ -91,8 +91,8 @@ class TestInfoCommand:
output = capsys.readouterr().out output = capsys.readouterr().out
assert "TEST_SVC_DATA_DIR" in output assert "TEST_SVC_DATA_DIR" in output
def test_info_shows_category(self, castle_root: Path, capsys) -> None: def test_info_shows_behavior(self, castle_root: Path, capsys) -> None:
"""Info displays category instead of roles.""" """Info displays behavior."""
from castle_cli.config import load_config from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("castle_cli.commands.info.load_config") as mock_load:
@@ -104,5 +104,5 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "category" in output assert "behavior" in output
assert "service" in output assert "daemon" in output

View File

@@ -19,7 +19,7 @@ class TestListCommand:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) 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) result = run_list(args)
assert result == 0 assert result == 0
@@ -27,13 +27,13 @@ class TestListCommand:
assert "test-svc" in captured.out assert "test-svc" in captured.out
assert "test-tool" in captured.out assert "test-tool" in captured.out
def test_list_filter_service(self, castle_root: Path, capsys: object) -> None: def test_list_filter_daemon(self, castle_root: Path, capsys: object) -> None:
"""List filtered to services.""" """List filtered to daemons."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load: with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) 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) result = run_list(args)
assert result == 0 assert result == 0
@@ -47,7 +47,7 @@ class TestListCommand:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) 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) result = run_list(args)
assert result == 0 assert result == 0
@@ -56,12 +56,12 @@ class TestListCommand:
assert "test-svc" not in captured.out assert "test-svc" not in captured.out
def test_list_filter_job(self, castle_root: Path, capsys: object) -> None: 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: with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) 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) result = run_list(args)
assert result == 0 assert result == 0
@@ -75,7 +75,7 @@ class TestListCommand:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) 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) result = run_list(args)
assert result == 0 assert result == 0
@@ -85,4 +85,4 @@ class TestListCommand:
assert "test-svc" in names assert "test-svc" in names
assert "test-tool" in names assert "test-tool" in names
svc = next(p for p in data if p["name"] == "test-svc") 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 description: str | None = None
source: str | None = None source: str | None = None
stack: str | None = None
install: InstallSpec | None = None install: InstallSpec | None = None
tool: ToolSpec | None = None tool: ToolSpec | None = None

View File

@@ -35,7 +35,8 @@ class DeployedComponent:
run_cmd: list[str] run_cmd: list[str]
env: dict[str, str] = field(default_factory=dict) env: dict[str, str] = field(default_factory=dict)
description: str | None = None description: str | None = None
category: str = "service" behavior: str = "daemon"
stack: str | None = None
port: int | None = None port: int | None = None
health_path: str | None = None health_path: str | None = None
proxy_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] = {} deployed: dict[str, DeployedComponent] = {}
for name, comp_data in data.get("deployed", {}).items(): 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( deployed[name] = DeployedComponent(
runner=comp_data.get("runner", "command"), runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []), run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}), env=comp_data.get("env", {}),
description=comp_data.get("description"), description=comp_data.get("description"),
category=comp_data.get("category", "service"), behavior=behavior,
stack=comp_data.get("stack"),
port=comp_data.get("port"), port=comp_data.get("port"),
health_path=comp_data.get("health_path"), health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_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 entry["env"] = comp.env
if comp.description: if comp.description:
entry["description"] = 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: if comp.port is not None:
entry["port"] = comp.port entry["port"] = comp.port
if comp.health_path: if comp.health_path:

View File

@@ -118,7 +118,7 @@ Discriminated union on `runner`:
|--------|------|--------|------------| |--------|------|--------|------------|
| `python` | `uv sync` | `which(tool)` → installed binary | `tool`, `args` | | `python` | `uv sync` | `which(tool)` → installed binary | `tool`, `args` |
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` | | `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` | | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` | | `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
@@ -209,10 +209,10 @@ semantics as services.
```bash ```bash
# Service — scaffolds project, assigns port, registers in castle.yaml # 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/ # Tool — scaffolds under components/
castle create my-tool --type tool --description "Does something" castle create my-tool --stack python-cli --description "Does something"
``` ```
### Manually ### Manually
@@ -256,7 +256,7 @@ services:
### Service lifecycle ### Service lifecycle
```bash ```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 cd components/my-service && uv sync # 2. Install deps
# ... implement ... # ... implement ...
castle test my-service # 3. Run tests castle test my-service # 3. Run tests
@@ -276,7 +276,7 @@ castle service disable my-service # Stop and remove systemd unit
### Tool lifecycle ### Tool lifecycle
```bash ```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 cd components/my-tool && uv sync # 2. Install deps
# ... implement ... # ... implement ...
castle test my-tool # 3. Run tests 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 Castle service is just a well-behaved Unix daemon that happens to
be registered in a manifest. be registered in a manifest.
3. **Section is category.** Components, services, and jobs live in 3. **Stack and behavior.** Each component has a *stack* (development
separate sections of `castle.yaml`. The section determines the toolchain: python-fastapi, python-cli, react-vite) and a *behavior*
category — no role derivation needed. (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, 4. **Language-agnostic above the build line.** Below the build line,
every language is different (uv, pnpm, cargo, go). Above it, every language is different (uv, pnpm, cargo, go). Above it,
@@ -210,7 +211,8 @@ deployed:
env: env:
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
CENTRAL_CONTEXT_PORT: "9001" CENTRAL_CONTEXT_PORT: "9001"
category: service behavior: daemon
stack: python-fastapi
port: 9001 port: 9001
health_path: /health health_path: /health
proxy_path: /central-context proxy_path: /central-context
@@ -321,32 +323,36 @@ Personal software platform
│ /devbox-api devbox-api 9020 devbox ● up │ │ /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 │ │ castle-api │ │ central-ctx │ │ notif-bridge │
│ ● up 5ms │ │ ● up 12ms │ │ ● up 8ms │ │ ● up 5ms │ │ ● up 12ms │ │ ● up 8ms │
│ :9020 │ │ :9001 │ │ :9002 │ │ :9020 │ │ :9001 │ │ :9002 │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
Jobs · Scheduled tasks with cron timers Components · Software catalog
Name Schedule Timer Name Stack Behavior Schedule Status
protonmail-sync */5 * * * * active pdf2md Python / CLI tool — installed
backup-collect 0 2 * * * active protonmail Python / CLI tool */5 * * * * installed
castle-app React / Vite frontend — —
Tools · CLI utilities installed to PATH backup-collect Python / CLI tool 0 2 * * * —
Name Status
pdf2md ● installed
gpt ● installed
``` ```
**Key components:** **Key components:**
- **GatewayPanel** — Route table with live health badges, reload button, - **GatewayPanel** — Route table with live health badges, reload button,
collapsible Caddyfile viewer. Node column appears when multi-node. 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 - **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node
mode. Each node links to `/node/{hostname}`. mode. Each node links to `/node/{hostname}`.
- **ServiceSection** — Service cards in a responsive grid. - **ServiceSection** — Daemon cards in a responsive grid.
- **JobSection** — Sortable table of scheduled tasks. - **ComponentTable** — Unified sortable table for all non-daemon components
- **ToolSection** — Sortable table of CLI tools with install/uninstall. (tools, frontends) with Stack, Behavior, Schedule, and Status columns.
**Real-time updates:** **Real-time updates:**
- SSE stream at `/stream` pushes `health`, `service-action`, and `mesh` - 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 - **Mesh infrastructure** — MQTT client (paho-mqtt), mDNS discovery
(python-zeroconf), MeshStateManager, all wired into API lifespan. (python-zeroconf), MeshStateManager, all wired into API lifespan.
Opt-in via `CASTLE_API_MQTT_ENABLED` / `CASTLE_API_MDNS_ENABLED`. 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. `GET /components?include_remote=true` for cross-node component listing.
- **Gateway panel** — Dedicated UI showing route table, health per route, - **Gateway panel** — Dedicated UI showing route table, health per route,
reload button, Caddyfile viewer. Cross-node routes shown when multi-node. 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, - **Node-aware UI** — NodeBar (hidden single-node), node detail page,
mesh SSE events for live node discovery updates. mesh SSE events for live node discovery updates.
- **Cross-node routing** — Caddyfile generator accepts remote registries, - **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) support them via `command` runner, but no examples exist yet)
- **Build automation** — Castle records build specs but doesn't - **Build automation** — Castle records build specs but doesn't
orchestrate builds (each project builds independently) orchestrate builds (each project builds independently)
- **Mosquitto deployment** — Registered in castle.yaml as `castle-mqtt` - **Multi-machine testing** — Mesh infrastructure is built and running
container service, but not yet deployed (needs `castle deploy` + on one node, but not yet tested with a second Castle node
container runtime)
## Technology Map ## Technology Map

View File

@@ -42,7 +42,7 @@ Examples: `components/pdf2md/`, `components/gpt/`, `components/protonmail/`
## Creating a new tool ## Creating a new tool
```bash ```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 cd components/my-tool && uv sync
``` ```

View File

@@ -433,8 +433,8 @@ uv run ruff format . # Format
`castle create` generates all of this automatically: `castle create` generates all of this automatically:
```bash ```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 See @docs/component-registry.md for manifest fields, castle.yaml structure,
the full service lifecycle (enable, logs, gateway reload). and the full service lifecycle (enable, logs, gateway reload).

View File

@@ -116,7 +116,7 @@ handles serving directly from the build output.
components: components:
my-frontend: my-frontend:
description: Web dashboard description: Web dashboard
source: my-frontend source: components/my-frontend
build: build:
commands: commands:
- ["pnpm", "build"] - ["pnpm", "build"]
@@ -124,8 +124,10 @@ components:
- dist/ - dist/
``` ```
For production, Caddy serves the static files — add a service entry with a For production, Caddy serves the static `dist/` output directly — no
proxy spec: Node process needed. See [Serving with Caddy](#serving-with-caddy) below.
For development with Vite's dev server, add a service entry:
```yaml ```yaml
services: services: