Refactor component terminology to programs in config and manifest

- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications.
- Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks.
- Adjusted tests to reflect the new program structure and ensure proper functionality.
- Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
This commit is contained in:
2026-02-23 22:09:41 -08:00
parent f559fba143
commit 0d36e4f72a
48 changed files with 7512 additions and 632 deletions

View File

@@ -9,21 +9,21 @@ Castle is a personal software platform — a monorepo of independent projects
(services, tools, libraries) managed by the `castle` CLI. The registry
(`castle.yaml`) has three top-level sections:
- **`components:`** — Software catalog (source, install, tool metadata, build)
- **`programs:`** — Software catalog (source, install, tool metadata, build)
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
Each component has a **stack** (development toolchain: python-fastapi,
Each program has a **stack** (development toolchain: python-fastapi,
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
frontend). Scheduling, systemd management, and proxying are orthogonal
operations. Services and jobs reference a component via `component:` for
operations. Services and jobs reference a program via `component:` for
description fallthrough.
**Key principle:** Regular projects must never depend on castle. They accept standard
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway)
configuration (data dir, port, URLs) via env vars. Only castle programs (CLI, gateway)
know about castle internals.
## Creating Components
## Creating Programs
When creating a new service, tool, or frontend, follow the detailed guides:
@@ -37,17 +37,17 @@ When creating a new service, tool, or frontend, follow the detailed guides:
```bash
# Daemon (python-fastapi)
castle create my-service --stack python-fastapi --description "Does something"
cd components/my-service && uv sync
cd programs/my-service && uv sync
uv run my-service # starts on auto-assigned port
castle service enable my-service # register with systemd
castle gateway reload # update reverse proxy routes
# Tool (python-cli)
castle create my-tool --stack python-cli --description "Does something"
cd components/my-tool && uv sync
cd programs/my-tool && uv sync
```
The `castle create` command scaffolds the project under `components/`,
The `castle create` command scaffolds the project under `programs/`,
generates a CLAUDE.md, and registers it in `castle.yaml`.
## Castle CLI
@@ -55,17 +55,17 @@ generates a CLAUDE.md, and registers it in `castle.yaml`.
The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
```bash
castle list # List all components
castle list # List all programs, services, and jobs
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 <name> # Show details (--json for machine-readable)
castle create <name> --stack python-fastapi # Scaffold new project
castle deploy [component] # Deploy to runtime (registry + systemd + Caddyfile)
castle deploy [name] # Deploy to runtime (registry + systemd + Caddyfile)
castle test [project] # Run tests (one or all)
castle lint [project] # Run linter (one or all)
castle sync # Update submodules + uv sync all
castle run <component> # Run component in foreground
castle logs <component> [-f] [-n 50] # View component logs
castle run <name> # Run service in foreground
castle logs <name> [-f] [-n 50] # View service/job logs
castle tool list # List all tools
castle tool info <name> # Show tool details
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
@@ -156,8 +156,8 @@ Services also support: `uv run <service-name>` to start.
## Key Files
- `castle.yaml`Component registry (three sections: components, services, jobs)
- `core/src/castle_core/manifest.py` — Pydantic models (ComponentSpec, ServiceSpec, JobSpec, RunSpec)
- `castle.yaml`Registry (three sections: programs, services, jobs)
- `core/src/castle_core/manifest.py` — Pydantic models (ProgramSpec, ServiceSpec, JobSpec, RunSpec)
- `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig)
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates

12
TODO.md
View File

@@ -19,3 +19,15 @@
- Add amplifier to make a component builder
- What's this about? "I need to add the MQTT env vars to the systemd unit. Let me add them via a drop-in override so the deploy command doesn't overwrite them."
- python-tool
- golang-tool
- rust-tool
- bash-tool
- python-daemon
- golang-daemon
- rust-daemon
- react-frontend
- python-webserver

View File

@@ -1,14 +1,13 @@
import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-react"
import { Link } from "react-router-dom"
import type { ComponentSummary, HealthStatus } from "@/types"
import type { ServiceSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
interface ComponentCardProps {
component: ComponentSummary
component: ServiceSummary
health?: HealthStatus
}
@@ -39,7 +38,6 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
</div>
<div className="flex gap-1.5 mb-2">
<BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
</div>

View File

@@ -1,11 +1,11 @@
import { useMemo, useState } from "react"
import { Check, Loader2, Save, Trash2 } from "lucide-react"
import type { ComponentDetail } from "@/types"
import type { AnyDetail } from "@/types"
import { runnerLabel } from "@/lib/labels"
import { SecretsEditor } from "./SecretsEditor"
interface ComponentFieldsProps {
component: ComponentDetail
component: AnyDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete?: (name: string) => Promise<void>
}
@@ -122,7 +122,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
</Field>
)}
{(component.managed || port) && (
{("managed" in component && component.managed || port) && (
<Field label="Port">
<input
value={port}
@@ -133,7 +133,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
</Field>
)}
{(component.managed || healthPath) && (
{("managed" in component && component.managed || healthPath) && (
<Field label="Health path">
<input
value={healthPath}
@@ -205,11 +205,13 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
</Field>
)}
<Field label="Systemd">
<span className="text-sm text-[var(--muted)]">
{component.managed ? "Yes" : "No"}
</span>
</Field>
{"managed" in component && (
<Field label="Systemd">
<span className="text-sm text-[var(--muted)]">
{component.managed ? "Yes" : "No"}
</span>
</Field>
)}
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
{onDelete ? (

View File

@@ -1,48 +0,0 @@
import type { ComponentSummary, HealthStatus } from "@/types"
import { BEHAVIOR_LABELS } from "@/lib/labels"
import { ComponentCard } from "./ComponentCard"
const BEHAVIOR_ORDER = ["daemon", "tool", "frontend"]
interface ComponentGridProps {
components: ComponentSummary[]
statuses: HealthStatus[]
}
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s]))
// Group by behavior
const groups = new Map<string, ComponentSummary[]>()
for (const comp of components) {
const key = comp.behavior ?? "other"
const list = groups.get(key) ?? []
list.push(comp)
groups.set(key, list)
}
return (
<div className="space-y-8">
{BEHAVIOR_ORDER.map((key) => {
const items = groups.get(key)
if (!items?.length) return null
return (
<section key={key}>
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
{BEHAVIOR_LABELS[key] ?? key}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((comp) => (
<ComponentCard
key={comp.id}
component={comp}
health={statusMap.get(comp.id)}
/>
))}
</div>
</section>
)
})}
</div>
)
}

View File

@@ -1,24 +1,18 @@
import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { ArrowDown, ArrowUp, ArrowUpDown, Plug, Unplug } from "lucide-react"
import type { ComponentSummary } from "@/types"
import { useToolAction } from "@/services/api/hooks"
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
import type { ProgramSummary } from "@/types"
import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
import { ProgramActions } from "./ProgramActions"
interface ComponentTableProps {
components: ComponentSummary[]
components: ProgramSummary[]
}
type SortKey = "id" | "stack" | "behavior" | "status"
type SortKey = "id" | "stack" | "behavior"
type SortDir = "asc" | "desc"
function installedRank(installed: boolean | null): number {
if (installed === false) return 0
if (installed === true) return 1
return 2
}
export function ComponentTable({ components }: ComponentTableProps) {
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("id")
@@ -53,8 +47,6 @@ export function ComponentTable({ components }: ComponentTableProps) {
return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
case "behavior":
return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
case "status":
return dir * (installedRank(a.installed) - installedRank(b.installed))
default:
return 0
}
@@ -67,7 +59,7 @@ export function ComponentTable({ components }: ComponentTableProps) {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter components..."
placeholder="Filter programs..."
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>
@@ -79,21 +71,17 @@ export function ComponentTable({ components }: ComponentTableProps) {
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Behavior" sortKey="behavior" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((comp) => (
<ComponentRow
key={comp.id}
component={comp}
/>
<ComponentRow key={comp.id} component={comp} />
))}
{sorted.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-[var(--muted)]">
No components match.
<td colSpan={4} className="px-3 py-6 text-center text-[var(--muted)]">
No programs match.
</td>
</tr>
)}
@@ -132,33 +120,12 @@ function SortHeader({
)
}
function InstalledBadge({ installed }: { installed: boolean }) {
return 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>
)
}
function ComponentRow({
component,
}: {
component: ComponentSummary
}) {
const isTool = component.installed !== null
const { mutate: toolAction, isPending: toolPending } = useToolAction()
function ComponentRow({ component }: { component: ProgramSummary }) {
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={`/components/${component.id}`}
to={`/programs/${component.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{component.id}
@@ -176,38 +143,7 @@ function ComponentRow({
<BehaviorBadge behavior={component.behavior} />
</td>
<td className="px-3 py-2.5">
{isTool ? (
<InstalledBadge installed={component.installed!} />
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{isTool ? (
<div className="flex items-center gap-1">
{component.installed ? (
<button
onClick={() => toolAction({ name: component.id, action: "uninstall" })}
disabled={toolPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Uninstall from PATH"
>
<Unplug size={14} />
</button>
) : (
<button
onClick={() => toolAction({ name: component.id, action: "install" })}
disabled={toolPending}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
title="Install to PATH"
>
<Plug size={14} />
</button>
)}
</div>
) : (
<span className="text-[var(--muted)]"></span>
)}
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} compact />
</td>
</tr>
)

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
import { apiClient } from "@/services/api/client"
interface LogViewerProps {
@@ -7,7 +7,14 @@ interface LogViewerProps {
follow?: boolean
}
export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
export interface LogViewerHandle {
clear: () => void
}
export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function LogViewer(
{ name, lines = 50, follow = true },
ref,
) {
const [logs, setLogs] = useState<string[]>([])
const [connected, setConnected] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
@@ -40,6 +47,8 @@ export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
return () => es.close()
}, [name, lines, follow])
useImperativeHandle(ref, () => ({ clear: () => setLogs([]) }), [])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
}, [logs])
@@ -68,4 +77,4 @@ export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
</pre>
</div>
)
}
})

View File

@@ -0,0 +1,167 @@
import { useState } from "react"
import {
FileCheck,
FlaskConical,
Hammer,
Loader2,
Plug,
ShieldCheck,
Sparkles,
Unplug,
type LucideIcon,
} from "lucide-react"
import { useProgramAction } from "@/services/api/hooks"
interface ActionConfig {
icon: LucideIcon
label: string
color: string
hoverBg: string
borderColor: string
}
const ACTION_CONFIG: Record<string, ActionConfig> = {
build: { icon: Hammer, label: "Build", color: "text-blue-400", hoverBg: "hover:bg-blue-800/30", borderColor: "border-blue-800" },
test: { icon: FlaskConical, label: "Test", color: "text-purple-400", hoverBg: "hover:bg-purple-800/30", borderColor: "border-purple-800" },
lint: { icon: Sparkles, label: "Lint", color: "text-amber-400", hoverBg: "hover:bg-amber-800/30", borderColor: "border-amber-800" },
"type-check": { icon: FileCheck, label: "Type Check", color: "text-cyan-400", hoverBg: "hover:bg-cyan-800/30", borderColor: "border-cyan-800" },
check: { icon: ShieldCheck, label: "Check All", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" },
install: { icon: Plug, label: "Install", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" },
uninstall: { icon: Unplug, label: "Uninstall", color: "text-red-400", hoverBg: "hover:bg-red-800/30", borderColor: "border-red-800" },
}
const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
interface ProgramActionsProps {
name: string
actions: string[]
installed?: boolean | null
compact?: boolean
}
function visibleActions(
actions: string[],
installed: boolean | null | undefined,
compact: boolean,
): string[] {
if (compact) {
// Table: only install/uninstall based on state
if (installed === true) return actions.includes("uninstall") ? ["uninstall"] : []
if (installed === false) return actions.includes("install") ? ["install"] : []
// null — show install if available (frontends, etc.)
return actions.includes("install") ? ["install"] : []
}
// Detail page: dev actions always, install/uninstall based on state
const visible: string[] = []
for (const a of DEV_ACTIONS) {
if (actions.includes(a)) visible.push(a)
}
if (installed === true) {
if (actions.includes("uninstall")) visible.push("uninstall")
} else if (installed === false) {
if (actions.includes("install")) visible.push("install")
} else {
// null — show both if available
if (actions.includes("install")) visible.push("install")
if (actions.includes("uninstall")) visible.push("uninstall")
}
return visible
}
export function ProgramActions({ name, actions, installed, compact }: ProgramActionsProps) {
const { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null)
const [output, setOutput] = useState<{ action: string; text: string; ok: boolean } | null>(null)
const visible = visibleActions(actions, installed, !!compact)
const handleAction = (action: string) => {
setRunningAction(action)
setOutput(null)
mutate(
{ name, action },
{
onSuccess: (data) => {
setRunningAction(null)
// Show output for dev actions on detail page
if (!compact && DEV_ACTIONS.includes(action) && data.output) {
setOutput({ action, text: data.output, ok: true })
}
},
onError: (err) => {
setRunningAction(null)
if (!compact && DEV_ACTIONS.includes(action)) {
// Extract detail from API error JSON
let text = String(err)
try {
const parsed = JSON.parse((err as Error).message)
text = parsed.detail ?? text
} catch {
text = (err as Error).message ?? text
}
setOutput({ action, text, ok: false })
}
},
},
)
}
if (visible.length === 0) return null
return (
<div>
<div className="flex items-center gap-1 flex-wrap">
{visible.map((action) => {
const config = ACTION_CONFIG[action]
if (!config) return null
const Icon = config.icon
const isRunning = isPending && runningAction === action
if (compact) {
return (
<button
key={action}
onClick={() => handleAction(action)}
disabled={isPending}
className={`p-1 rounded ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
title={config.label}
>
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
</button>
)
}
return (
<button
key={action}
onClick={() => handleAction(action)}
disabled={isPending}
className={`flex items-center gap-1.5 px-2 py-1 text-sm rounded border ${config.borderColor} ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
>
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
{config.label}
</button>
)
})}
</div>
{output && !compact && (
<div className={`mt-3 rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}>
<div className={`flex items-center justify-between px-3 py-1.5 text-xs ${output.ok ? "text-green-400 bg-green-900/20" : "text-red-400 bg-red-900/20"}`}>
<span>{ACTION_CONFIG[output.action]?.label ?? output.action} {output.ok ? "ok" : "error"}</span>
<button
onClick={() => setOutput(null)}
className="text-[var(--muted)] hover:text-[var(--foreground)]"
>
dismiss
</button>
</div>
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-64 overflow-y-auto bg-black/40">
{output.text}
</pre>
</div>
)}
</div>
)
}

View File

@@ -1,12 +1,12 @@
import { Link } from "react-router-dom"
import { Play, RefreshCw, Square } from "lucide-react"
import type { ComponentSummary, HealthStatus } from "@/types"
import type { JobSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { SectionHeader } from "./SectionHeader"
import { StackBadge } from "./StackBadge"
interface ScheduledSectionProps {
jobs: ComponentSummary[]
jobs: JobSummary[]
statuses: HealthStatus[]
}
@@ -38,7 +38,7 @@ export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) {
)
}
function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthStatus }) {
function ScheduledRow({ job, health }: { job: JobSummary; health?: HealthStatus }) {
const { mutate, isPending } = useServiceAction()
const isDown = health?.status === "down"

View File

@@ -1,10 +1,10 @@
import { useMemo } from "react"
import type { ComponentSummary, HealthStatus } from "@/types"
import type { ServiceSummary, HealthStatus } from "@/types"
import { ComponentCard } from "./ComponentCard"
import { SectionHeader } from "./SectionHeader"
interface ServiceSectionProps {
services: ComponentSummary[]
services: ServiceSummary[]
statuses: HealthStatus[]
}

View File

@@ -3,12 +3,12 @@ import { useNavigate } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { Check } from "lucide-react"
import { apiClient } from "@/services/api/client"
import type { ComponentDetail } from "@/types"
import type { AnyDetail } from "@/types"
import { ComponentFields } from "@/components/ComponentFields"
interface ConfigPanelProps {
component: ComponentDetail
configSection: "services" | "jobs" | "components"
component: AnyDetail
configSection: "services" | "jobs" | "programs"
onRefetch: () => void
}
@@ -23,7 +23,7 @@ export function ConfigPanel({ component, configSection, onRefetch }: ConfigPanel
await apiClient.put(`/config/${configSection}/${compName}`, { config })
setMessage({ type: "ok", text: "Saved to castle.yaml" })
onRefetch()
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: [configSection] })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setMessage({ type: "error", text: msg })
@@ -33,7 +33,7 @@ export function ConfigPanel({ component, configSection, onRefetch }: ConfigPanel
const handleDelete = async (compName: string) => {
try {
await apiClient.delete(`/config/${configSection}/${compName}`)
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: [configSection] })
navigate("/")
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)

View File

@@ -33,7 +33,7 @@ export const STACK_LABELS: Record<string, string> = {
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
service: { title: "Services", subtitle: "Long-running processes" },
scheduled: { title: "Scheduled Jobs", subtitle: "Systemd timers" },
component: { title: "Components", subtitle: "Software catalog" },
program: { title: "Programs", subtitle: "Software catalog" },
}
export function runnerLabel(runner: string): string {

View File

@@ -1,17 +1,16 @@
import { useParams } from "react-router-dom"
import { Plug, Unplug } from "lucide-react"
import { useComponent, useEventStream, useToolDetail, useToolAction } from "@/services/api/hooks"
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { ProgramActions } from "@/components/ProgramActions"
export function ComponentDetailPage() {
useEventStream()
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
const isTool = component?.behavior === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const { mutate: toolAction, isPending: toolPending } = useToolAction()
if (isLoading) {
return (
@@ -23,7 +22,7 @@ export function ComponentDetailPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader backTo="/" backLabel="Back" name={name ?? ""} />
<p className="text-red-400">Component not found</p>
<p className="text-red-400">Program not found</p>
</div>
)
}
@@ -32,33 +31,13 @@ export function ComponentDetailPage() {
<div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader
backTo="/"
backLabel="Back to Components"
backLabel="Back to Programs"
name={component.id}
behavior={component.behavior}
stack={component.stack}
source={component.source}
>
{isTool && (
<div className="flex items-center gap-2">
{component.installed ? (
<button
onClick={() => toolAction({ name: component.id, action: "uninstall" })}
disabled={toolPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-red-800 text-red-400 hover:bg-red-800/20 transition-colors disabled:opacity-40"
>
<Unplug size={14} /> Uninstall
</button>
) : (
<button
onClick={() => toolAction({ name: component.id, action: "install" })}
disabled={toolPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-green-800 text-green-400 hover:bg-green-800/20 transition-colors disabled:opacity-40"
>
<Plug size={14} /> Install to PATH
</button>
)}
</div>
)}
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} />
</DetailHeader>
{component.description && (
@@ -121,7 +100,7 @@ export function ComponentDetailPage() {
</div>
)}
<ConfigPanel component={component} configSection="components" onRefetch={refetch} />
<ConfigPanel component={component} configSection="programs" onRefetch={refetch} />
</div>
)
}

View File

@@ -12,7 +12,7 @@ export function ComponentRedirect() {
}
if (error || !component) {
return <Navigate to={`/components/${name}`} replace />
return <Navigate to={`/programs/${name}`} replace />
}
if (component.managed && !component.schedule) {
@@ -20,8 +20,8 @@ export function ComponentRedirect() {
}
if (component.managed && component.schedule) {
return <Navigate to={`/scheduled/${name}`} replace />
return <Navigate to={`/jobs/${name}`} replace />
}
return <Navigate to={`/components/${name}`} replace />
return <Navigate to={`/programs/${name}`} replace />
}

View File

@@ -1,5 +1,4 @@
import { useMemo } from "react"
import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
import { useServices, useJobs, usePrograms, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
import { GatewayPanel } from "@/components/GatewayPanel"
import { MeshPanel } from "@/components/MeshPanel"
import { NodeBar } from "@/components/NodeBar"
@@ -11,20 +10,16 @@ import { SectionHeader } from "@/components/SectionHeader"
export function Dashboard() {
useEventStream()
const { data: components, isLoading } = useComponents()
const { data: services, isLoading: loadingServices } = useServices()
const { data: jobs, isLoading: loadingJobs } = useJobs()
const { data: programs, isLoading: loadingPrograms } = usePrograms()
const { data: statusResp } = useStatus()
const { data: gateway } = useGateway()
const { data: nodes } = useNodes()
const { data: mesh } = useMeshStatus()
const { services, scheduled, catalog } = useMemo(() => {
const svc = (components ?? []).filter((c) => c.category === "service")
const sch = (components ?? []).filter((c) => c.category === "job")
const cat = (components ?? []).filter((c) => c.category === "component")
return { services: svc, scheduled: sch, catalog: cat }
}, [components])
const statuses = statusResp?.statuses ?? []
const isLoading = loadingServices || loadingJobs || loadingPrograms
return (
<div className="max-w-6xl mx-auto px-6 py-8">
@@ -51,16 +46,16 @@ export function Dashboard() {
<p className="text-[var(--muted)]">Loading...</p>
) : (
<div className="space-y-10">
{services.length > 0 && (
{services && services.length > 0 && (
<ServiceSection services={services} statuses={statuses} />
)}
{scheduled.length > 0 && (
<ScheduledSection jobs={scheduled} statuses={statuses} />
{jobs && jobs.length > 0 && (
<ScheduledSection jobs={jobs} statuses={statuses} />
)}
{catalog.length > 0 && (
{programs && programs.length > 0 && (
<section>
<SectionHeader section="component" />
<ComponentTable components={catalog} />
<SectionHeader section="program" />
<ComponentTable components={programs} />
</section>
)}
</div>

View File

@@ -1,7 +1,8 @@
import { useRef } from "react"
import { useParams } from "react-router-dom"
import { Clock } from "lucide-react"
import { useComponent, useStatus, useEventStream } from "@/services/api/hooks"
import { LogViewer } from "@/components/LogViewer"
import { Clock, Trash2 } from "lucide-react"
import { useJob, useStatus, useEventStream } from "@/services/api/hooks"
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ServiceControls } from "@/components/detail/ServiceControls"
import { SystemdPanel } from "@/components/detail/SystemdPanel"
@@ -9,8 +10,9 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
export function ScheduledDetailPage() {
useEventStream()
const logRef = useRef<LogViewerHandle>(null)
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
const { data: component, isLoading, error, refetch } = useJob(name ?? "")
const { data: statusResp } = useStatus()
const health = statusResp?.statuses.find((s) => s.id === name)
@@ -35,7 +37,7 @@ export function ScheduledDetailPage() {
backTo="/"
backLabel="Back to Jobs"
name={component.id}
behavior={component.behavior}
behavior="tool"
stack={component.stack}
source={component.source}
>
@@ -73,8 +75,16 @@ export function ScheduledDetailPage() {
{component.managed && (
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer name={component.id} />
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Logs</h2>
<button
onClick={() => logRef.current?.clear()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
>
<Trash2 size={14} /> Clear
</button>
</div>
<LogViewer ref={logRef} name={component.id} />
</div>
)}
</div>

View File

@@ -1,9 +1,10 @@
import { useRef } from "react"
import { useParams } from "react-router-dom"
import { Server, ExternalLink, Terminal } from "lucide-react"
import { useComponent, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
import { Server, ExternalLink, Terminal, Trash2 } from "lucide-react"
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { HealthBadge } from "@/components/HealthBadge"
import { LogViewer } from "@/components/LogViewer"
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ServiceControls } from "@/components/detail/ServiceControls"
import { SystemdPanel } from "@/components/detail/SystemdPanel"
@@ -11,8 +12,9 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
export function ServiceDetailPage() {
useEventStream()
const logRef = useRef<LogViewerHandle>(null)
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
const { data: component, isLoading, error, refetch } = useService(name ?? "")
const { data: statusResp } = useStatus()
const health = statusResp?.statuses.find((s) => s.id === name)
const isGateway = name === "castle-gateway"
@@ -41,7 +43,7 @@ export function ServiceDetailPage() {
backTo="/"
backLabel="Back to Services"
name={component.id}
behavior={component.behavior}
behavior="daemon"
stack={component.stack}
source={component.source}
>
@@ -129,8 +131,16 @@ export function ServiceDetailPage() {
{component.managed && (
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer name={component.id} />
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Logs</h2>
<button
onClick={() => logRef.current?.clear()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
>
<Trash2 size={14} /> Clear
</button>
</div>
<LogViewer ref={logRef} name={component.id} />
</div>
)}
</div>

View File

@@ -20,7 +20,7 @@ export const router = createBrowserRouter([
element: <ScheduledDetailPage />,
},
{
path: "/components/:name",
path: "/programs/:name",
element: <ComponentDetailPage />,
},
{

View File

@@ -2,8 +2,13 @@ import { useEffect } from "react"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import { apiClient } from "./client"
import type {
ComponentSummary,
ComponentDetail,
ServiceSummary,
ServiceDetail,
JobSummary,
JobDetail,
ProgramSummary,
ProgramDetail,
StatusResponse,
GatewayInfo,
ServiceActionResponse,
@@ -15,13 +20,7 @@ import type {
ToolDetail,
} from "@/types"
export function useComponents() {
return useQuery({
queryKey: ["components"],
queryFn: () => apiClient.get<ComponentSummary[]>("/components"),
})
}
// Legacy compat hook — used by ConfigEditorPage and ComponentRedirect
export function useComponent(name: string) {
return useQuery({
queryKey: ["components", name],
@@ -30,6 +29,51 @@ export function useComponent(name: string) {
})
}
export function useServices() {
return useQuery({
queryKey: ["services"],
queryFn: () => apiClient.get<ServiceSummary[]>("/services"),
})
}
export function useService(name: string) {
return useQuery({
queryKey: ["services", name],
queryFn: () => apiClient.get<ServiceDetail>(`/services/${name}`),
enabled: !!name,
})
}
export function useJobs() {
return useQuery({
queryKey: ["jobs"],
queryFn: () => apiClient.get<JobSummary[]>("/jobs"),
})
}
export function useJob(name: string) {
return useQuery({
queryKey: ["jobs", name],
queryFn: () => apiClient.get<JobDetail>(`/jobs/${name}`),
enabled: !!name,
})
}
export function usePrograms() {
return useQuery({
queryKey: ["programs"],
queryFn: () => apiClient.get<ProgramSummary[]>("/programs"),
})
}
export function useProgram(name: string) {
return useQuery({
queryKey: ["programs", name],
queryFn: () => apiClient.get<ProgramDetail>(`/programs/${name}`),
enabled: !!name,
})
}
export function useStatus() {
return useQuery({
queryKey: ["status"],
@@ -97,15 +141,15 @@ export function useServiceAction() {
})
}
export function useToolAction() {
export function useProgramAction() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ name, action }: { name: string; action: "install" | "uninstall" }) =>
apiClient.post<{ component: string; action: string; status: string }>(
`/tools/${name}/${action}`,
mutationFn: ({ name, action }: { name: string; action: string }) =>
apiClient.post<{ component: string; action: string; status: string; output: string }>(
`/programs/${name}/${action}`,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: ["programs"] })
},
})
}
@@ -171,9 +215,10 @@ export function useEventStream() {
})
es.addEventListener("service-action", () => {
// Health event already pushes correct status; just refetch components
// Health event already pushes correct status; just refetch services/jobs
// in case the action changed what's available
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: ["services"] })
qc.invalidateQueries({ queryKey: ["jobs"] })
})
es.addEventListener("mesh", () => {

View File

@@ -4,9 +4,65 @@ export interface SystemdInfo {
timer: boolean
}
export interface ServiceSummary {
id: string
description: string | null
stack: string | null
runner: string | null
port: number | null
health_path: string | null
proxy_path: string | null
managed: boolean
systemd: SystemdInfo | null
source: string | null
node: string | null
}
export interface ServiceDetail extends ServiceSummary {
manifest: Record<string, unknown>
}
export interface JobSummary {
id: string
description: string | null
stack: string | null
runner: string | null
schedule: string | null
managed: boolean
systemd: SystemdInfo | null
source: string | null
node: string | null
}
export interface JobDetail extends JobSummary {
manifest: Record<string, unknown>
}
export interface ProgramSummary {
id: string
description: string | null
behavior: string | null
stack: string | null
runner: string | null
version: string | null
source: string | null
system_dependencies: string[]
installed: boolean | null
actions: string[]
node: string | null
}
export interface ProgramDetail extends ProgramSummary {
manifest: Record<string, unknown>
}
// Union for shared detail components (ConfigPanel, ComponentFields)
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
export interface ComponentSummary {
id: string
category: "component" | "service" | "job" | null
category: "program" | "service" | "job" | null
description: string | null
behavior: string | null
stack: string | null

View File

@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core.config import save_config
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast
@@ -28,7 +28,7 @@ class ConfigSaveRequest(BaseModel):
class ConfigSaveResponse(BaseModel):
ok: bool
component_count: int
program_count: int
service_count: int
job_count: int
errors: list[str]
@@ -40,7 +40,7 @@ class ApplyResponse(BaseModel):
errors: list[str]
class ComponentConfigRequest(BaseModel):
class ProgramConfigRequest(BaseModel):
config: dict
@@ -92,16 +92,17 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
detail="YAML must be a mapping",
)
# Validate components
comp_count = 0
for name, comp_data in data.get("components", {}).items():
# Validate programs
prog_count = 0
programs_data = data.get("programs") or data.get("components") or {}
for name, comp_data in programs_data.items():
try:
comp_data_copy = dict(comp_data) if comp_data else {}
comp_data_copy["id"] = name
ComponentSpec.model_validate(comp_data_copy)
comp_count += 1
ProgramSpec.model_validate(comp_data_copy)
prog_count += 1
except Exception as e:
errors.append(f"components.{name}: {e}")
errors.append(f"programs.{name}: {e}")
# Validate services
svc_count = 0
@@ -138,46 +139,46 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
config_path.write_text(request.yaml_content)
return ConfigSaveResponse(
ok=True, component_count=comp_count, service_count=svc_count,
ok=True, program_count=prog_count, service_count=svc_count,
job_count=job_count, errors=[],
)
@router.put("/components/{name}")
def save_component(name: str, request: ComponentConfigRequest) -> dict:
"""Update a single component's config in castle.yaml."""
@router.put("/programs/{name}")
def save_program(name: str, request: ProgramConfigRequest) -> dict:
"""Update a single program's config in castle.yaml."""
_require_repo()
try:
comp_data = dict(request.config)
comp_data["id"] = name
ComponentSpec.model_validate(comp_data)
prog_data = dict(request.config)
prog_data["id"] = name
ProgramSpec.model_validate(prog_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid component config: {e}",
detail=f"Invalid program config: {e}",
)
config = get_config()
config.components[name] = ComponentSpec.model_validate(
config.programs[name] = ProgramSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
return {"ok": True, "component": name}
return {"ok": True, "program": name}
@router.delete("/components/{name}")
def delete_component(name: str) -> dict:
"""Remove a component from castle.yaml."""
@router.delete("/programs/{name}")
def delete_program(name: str) -> dict:
"""Remove a program from castle.yaml."""
config = get_config()
if name not in config.components:
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
detail=f"Program '{name}' not found",
)
del config.components[name]
del config.programs[name]
save_config(config)
return {"ok": True, "component": name, "action": "deleted"}
return {"ok": True, "program": name, "action": "deleted"}
@router.put("/services/{name}")

View File

@@ -15,7 +15,7 @@ class ComponentSummary(BaseModel):
"""Summary of a single component."""
id: str
category: str | None = None # "component", "service", or "job"
category: str | None = None # "program", "service", or "job"
description: str | None = None
behavior: str | None = None
stack: str | None = None
@@ -39,6 +39,70 @@ class ComponentDetail(ComponentSummary):
manifest: dict
class ServiceSummary(BaseModel):
"""Summary of a service (long-running daemon)."""
id: str
description: str | None = None
stack: str | None = None
runner: str | None = None
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
managed: bool = False
systemd: SystemdInfo | None = None
source: str | None = None
node: str | None = None
class ServiceDetail(ServiceSummary):
"""Full detail for a service, including raw manifest."""
manifest: dict
class JobSummary(BaseModel):
"""Summary of a job (scheduled task)."""
id: str
description: str | None = None
stack: str | None = None
runner: str | None = None
schedule: str | None = None
managed: bool = False
systemd: SystemdInfo | None = None
source: str | None = None
node: str | None = None
class JobDetail(JobSummary):
"""Full detail for a job, including raw manifest."""
manifest: dict
class ProgramSummary(BaseModel):
"""Summary of a program (software catalog entry)."""
id: str
description: str | None = None
behavior: str | None = None
stack: str | None = None
runner: str | None = None
version: str | None = None
source: str | None = None
system_dependencies: list[str] = []
installed: bool | None = None
actions: list[str] = []
node: str | None = None
class ProgramDetail(ProgramSummary):
"""Full detail for a program, including raw manifest."""
manifest: dict
class HealthStatus(BaseModel):
"""Health status of a single component."""

View File

@@ -10,7 +10,8 @@ from fastapi import APIRouter, HTTPException, status
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
from castle_core.stacks import available_actions
from castle_api.config import get_castle_root, get_registry
from castle_api.mesh import mesh_state
@@ -20,6 +21,12 @@ from castle_api.models import (
ComponentSummary,
GatewayInfo,
GatewayRoute,
JobDetail,
JobSummary,
ProgramDetail,
ProgramSummary,
ServiceDetail,
ServiceSummary,
StatusResponse,
SystemdInfo,
)
@@ -88,8 +95,8 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.components:
comp = config.components[svc.component]
if svc.component and svc.component in config.programs:
comp = config.programs[svc.component]
if not description:
description = comp.description
source = comp.source
@@ -126,8 +133,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
description = job.description
source = None
stack = None
if job.component and job.component in config.components:
comp = config.components[job.component]
if job.component and job.component in config.programs:
comp = config.programs[job.component]
if not description:
description = comp.description
source = comp.source
@@ -147,8 +154,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
)
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
# Determine behavior
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
@@ -178,7 +185,7 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
return ComponentSummary(
id=name,
category="component",
category="program",
description=comp.description,
behavior=behavior,
stack=comp.stack,
@@ -190,6 +197,454 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
)
# ---------------------------------------------------------------------------
# Typed builder functions — one per concept
# ---------------------------------------------------------------------------
def _make_systemd_info(name: str, timer: bool = False) -> SystemdInfo:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
return SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=timer)
def _backfill_source(name: str, config: object) -> str | None:
"""Resolve source path from program ref in config."""
if name in config.programs:
return config.programs[name].source
ref = None
if name in config.services:
ref = config.services[name].component
elif name in config.jobs:
ref = config.jobs[name].component
if ref and ref in config.programs:
return config.programs[ref].source
return None
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
"""Build a ServiceSummary from a DeployedComponent."""
systemd_info = _make_systemd_info(name) if deployed.managed else None
return ServiceSummary(
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
proxy_path=deployed.proxy_path,
managed=deployed.managed,
systemd=systemd_info,
)
def _service_from_spec(
name: str, svc: ServiceSpec, config: object
) -> ServiceSummary:
"""Build a ServiceSummary from a ServiceSpec."""
port = None
health_path = None
proxy_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
health_path = svc.expose.http.health_path
if svc.proxy and svc.proxy.caddy:
proxy_path = svc.proxy.caddy.path_prefix
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
systemd_info = _make_systemd_info(name) if managed else None
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.programs:
comp = config.programs[svc.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return ServiceSummary(
id=name,
description=description,
stack=stack,
runner=svc.run.runner,
port=port,
health_path=health_path,
proxy_path=proxy_path,
managed=managed,
systemd=systemd_info,
source=source,
)
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
"""Build a JobSummary from a DeployedComponent."""
systemd_info = (
_make_systemd_info(name, timer=True) if deployed.managed else None
)
return JobSummary(
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
schedule=deployed.schedule,
managed=deployed.managed,
systemd=systemd_info,
)
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
"""Build a JobSummary from a JobSpec."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info = _make_systemd_info(name, timer=True) if managed else None
description = job.description
source = None
stack = None
if job.component and job.component in config.programs:
comp = config.programs[job.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return JobSummary(
id=name,
description=description,
stack=stack,
runner=job.run.runner,
schedule=job.schedule,
managed=managed,
systemd=systemd_info,
source=source,
)
def _program_from_spec(
name: str, comp: ProgramSpec, root: Path, config: object | None = None
) -> ProgramSummary:
"""Build a ProgramSummary from a ProgramSpec."""
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool:
behavior = "tool"
elif is_frontend:
behavior = "frontend"
else:
behavior = None
# Derive behavior from backing service/job
if behavior is None and config is not None:
svc_components = {
s.component for s in config.services.values() if s.component
}
job_components = {
j.component for j in config.jobs.values() if j.component
}
if name in svc_components or name in config.services:
behavior = "daemon"
elif name in job_components or name in config.jobs:
behavior = "tool"
source = comp.source
runner = None
if source:
source_dir = root / source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.install and comp.install.path:
alias = comp.install.path.alias or name
installed = shutil.which(alias) is not None
elif config is not None:
# Daemons: check if the service's run.tool binary is on PATH
svc = config.services.get(name)
if svc and hasattr(svc.run, "tool"):
installed = shutil.which(svc.run.tool) is not None
return ProgramSummary(
id=name,
description=comp.description,
behavior=behavior,
stack=comp.stack,
runner=runner,
version=comp.tool.version if comp.tool else None,
source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
installed=installed,
actions=available_actions(comp),
)
# ---------------------------------------------------------------------------
# Typed endpoints — /services, /jobs, /programs
# ---------------------------------------------------------------------------
@router.get("/services", response_model=list[ServiceSummary], tags=["services-data"])
def list_services(include_remote: bool = False) -> list[ServiceSummary]:
"""List all services — deployed from registry, non-deployed from castle.yaml."""
registry = get_registry()
hostname = registry.node.hostname
summaries: list[ServiceSummary] = []
seen: set[str] = set()
# Deployed services (non-scheduled)
for name, deployed in registry.deployed.items():
if deployed.schedule:
continue
s = _service_from_deployed(name, deployed)
s.node = hostname
# Backfill source
root = get_castle_root()
if root and s.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, svc in config.services.items():
if name not in seen:
s = _service_from_spec(name, svc, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote
if include_remote:
for remote_host, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if not d.schedule and name not in seen:
s = _service_from_deployed(name, d)
s.node = remote_host
summaries.append(s)
seen.add(name)
return summaries
@router.get(
"/services/{name}",
response_model=ServiceDetail,
tags=["services-data"],
)
def get_service(name: str) -> ServiceDetail:
"""Get detailed info for a single service."""
registry = get_registry()
if name in registry.deployed and not registry.deployed[name].schedule:
deployed = registry.deployed[name]
summary = _service_from_deployed(name, deployed)
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
summary.source = _backfill_source(name, config)
except FileNotFoundError:
pass
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return ServiceDetail(**summary.model_dump(), manifest=raw)
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.services:
svc = config.services[name]
summary = _service_from_spec(name, svc, config)
raw = svc.model_dump(mode="json", exclude_none=True)
return ServiceDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{name}' not found",
)
@router.get("/jobs", response_model=list[JobSummary], tags=["jobs-data"])
def list_jobs(include_remote: bool = False) -> list[JobSummary]:
"""List all jobs — deployed from registry, non-deployed from castle.yaml."""
registry = get_registry()
hostname = registry.node.hostname
summaries: list[JobSummary] = []
seen: set[str] = set()
# Deployed jobs (scheduled)
for name, deployed in registry.deployed.items():
if not deployed.schedule:
continue
s = _job_from_deployed(name, deployed)
s.node = hostname
root = get_castle_root()
if root and s.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, job in config.jobs.items():
if name not in seen:
s = _job_from_spec(name, job, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote
if include_remote:
for remote_host, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if d.schedule and name not in seen:
s = _job_from_deployed(name, d)
s.node = remote_host
summaries.append(s)
seen.add(name)
return summaries
@router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"])
def get_job(name: str) -> JobDetail:
"""Get detailed info for a single job."""
registry = get_registry()
if name in registry.deployed and registry.deployed[name].schedule:
deployed = registry.deployed[name]
summary = _job_from_deployed(name, deployed)
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
summary.source = _backfill_source(name, config)
except FileNotFoundError:
pass
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"managed": deployed.managed,
"schedule": deployed.schedule,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return JobDetail(**summary.model_dump(), manifest=raw)
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.jobs:
job = config.jobs[name]
summary = _job_from_spec(name, job, config)
raw = job.model_dump(mode="json", exclude_none=True)
return JobDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job '{name}' not found",
)
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
def list_programs() -> list[ProgramSummary]:
"""List all programs from the software catalog (castle.yaml programs section)."""
root = get_castle_root()
if not root:
return []
try:
from castle_core.config import load_config
config = load_config(root)
except FileNotFoundError:
return []
hostname = get_registry().node.hostname
summaries: list[ProgramSummary] = []
for name, comp in config.programs.items():
summary = _program_from_spec(name, comp, root, config)
if summary.behavior is None:
continue
summary.node = hostname
summaries.append(summary)
return summaries
@router.get("/programs/{name}", response_model=ProgramDetail, tags=["programs"])
def get_program(name: str) -> ProgramDetail:
"""Get detailed info for a single program."""
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.programs:
comp = config.programs[name]
summary = _program_from_spec(name, comp, root, config)
raw = comp.model_dump(mode="json", exclude_none=True)
return ProgramDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Program '{name}' not found",
)
# ---------------------------------------------------------------------------
# Legacy /components endpoint (compat shim)
# ---------------------------------------------------------------------------
@router.get("/components", response_model=list[ComponentSummary])
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
"""List all components — deployed from registry, non-deployed from castle.yaml.
@@ -232,33 +687,37 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
summaries.append(s)
seen.add(name)
# Backfill source from component refs for deployed items
# Backfill source from program refs for deployed items
for s in summaries:
if s.source is None and s.id in config.components:
s.source = config.components[s.id].source
if s.source is None and s.id in config.programs:
s.source = config.programs[s.id].source
elif s.source is None:
# Check if a service/job references a component
# Check if a service/job references a program
ref = None
if s.id in config.services:
ref = config.services[s.id].component
elif s.id in config.jobs:
ref = config.jobs[s.id].component
if ref and ref in config.components:
s.source = config.components[ref].source
if ref and ref in config.programs:
s.source = config.programs[ref].source
# Components (tools/frontends) not already represented by a
# service or job entry. Skip if the component has no
# distinct behavior (e.g. it's just the software identity
# behind a service).
for name, comp in config.components.items():
if name in seen:
continue
summary = _summary_from_component(name, comp, root)
# Programs — always include entries from the programs
# section as catalog items. Derive behavior from backing
# service/job when the program has no install/build spec.
svc_components = {s.component for s in config.services.values() if s.component}
job_components = {j.component for j in config.jobs.values() if j.component}
for name, comp in config.programs.items():
summary = _summary_from_program(name, comp, root)
if summary.behavior is None:
continue
if name in svc_components or name in config.services:
summary.behavior = "daemon"
elif name in job_components or name in config.jobs:
summary.behavior = "tool"
else:
continue
summary.node = local_hostname
summaries.append(summary)
seen.add(name)
except FileNotFoundError:
pass
@@ -297,23 +756,23 @@ def get_component(name: str) -> ComponentDetail:
deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed)
# Backfill source from castle.yaml component ref
# Backfill source from castle.yaml program ref
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
summary.source = config.components[name].source
if name in config.programs:
summary.source = config.programs[name].source
else:
ref = None
if name in config.services:
ref = config.services[name].component
elif name in config.jobs:
ref = config.jobs[name].component
if ref and ref in config.components:
summary.source = config.components[ref].source
if ref and ref in config.programs:
summary.source = config.programs[ref].source
except FileNotFoundError:
pass
@@ -349,9 +808,9 @@ def get_component(name: str) -> ComponentDetail:
raw = job.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
if name in config.components:
comp = config.components[name]
summary = _summary_from_component(name, comp, root)
if name in config.programs:
comp = config.programs[name]
summary = _summary_from_program(name, comp, root)
raw = comp.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)

View File

@@ -1,13 +1,13 @@
"""Tools router — browse and inspect tool components."""
"""Tools router and program actions."""
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.manifest import ComponentSpec
from castle_core.manifest import ProgramSpec
from castle_core.stacks import available_actions, get_handler
from castle_api.config import get_config
from castle_api.models import ToolDetail, ToolSummary
@@ -15,15 +15,15 @@ from castle_api.models import ToolDetail, ToolSummary
router = APIRouter(tags=["tools"])
def _is_tool(comp: ComponentSpec) -> bool:
def _is_tool(comp: ProgramSpec) -> bool:
"""Check if a component is a tool (has install.path or tool spec)."""
return bool((comp.install and comp.install.path) or comp.tool)
def _tool_summary(
name: str, comp: ComponentSpec, root: Path | None = None
name: str, comp: ProgramSpec, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a ComponentSpec that is a tool."""
"""Build a ToolSummary from a ProgramSpec that is a tool."""
t = comp.tool
installed = bool(
comp.install and comp.install.path and comp.install.path.enable
@@ -54,7 +54,7 @@ def _tool_summary(
def list_tools() -> list[ToolSummary]:
"""List all registered tools (requires repo access)."""
config = get_config()
tools = {k: v for k, v in config.components.items() if _is_tool(v)}
tools = {k: v for k, v in config.programs.items() if _is_tool(v)}
return sorted(
[
@@ -70,13 +70,13 @@ def get_tool(name: str) -> ToolDetail:
"""Get detailed info for a single tool."""
config = get_config()
if name not in config.components:
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
)
comp = config.components[name]
comp = config.programs[name]
if not _is_tool(comp):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -87,82 +87,54 @@ def get_tool(name: str) -> ToolDetail:
return ToolDetail(**summary.model_dump())
@router.post("/tools/{name}/install")
async def install_tool(name: str) -> dict:
"""Install a tool to PATH via uv tool install."""
# ---------------------------------------------------------------------------
# Unified program action endpoint
# ---------------------------------------------------------------------------
_VALID_ACTIONS = {"build", "test", "lint", "type-check", "check", "install", "uninstall"}
@router.post("/programs/{name}/{action}")
async def program_action(name: str, action: str) -> dict:
"""Run a lifecycle action on a program via its stack handler."""
if action not in _VALID_ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
config = get_config()
if name not in config.components:
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
comp = config.components[name]
comp = config.programs[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
actions = available_actions(comp)
if action not in actions:
raise HTTPException(
status_code=400, detail=f"'{name}' has no source to install"
status_code=400,
detail=f"Action '{action}' not available for '{name}' (stack: {comp.stack})",
)
source_dir = config.root / comp.source
if not (source_dir / "pyproject.toml").exists():
handler = get_handler(comp.stack)
if handler is None:
raise HTTPException(
status_code=400, detail=f"No pyproject.toml in {comp.source}"
status_code=400, detail=f"No handler for stack '{comp.stack}'"
)
proc = await asyncio.create_subprocess_exec(
"uv",
"tool",
"install",
"--editable",
str(source_dir),
"--force",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
output = (stdout or stderr or b"").decode().strip()
# Map hyphenated action names to method names (type-check → type_check)
method_name = action.replace("-", "_")
method = getattr(handler, method_name)
if proc.returncode != 0:
raise HTTPException(status_code=500, detail=output or "Install failed")
result = await method(name, comp, config.root)
return {"component": name, "action": "install", "status": "ok"}
if result.status != "ok":
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
@router.post("/tools/{name}/uninstall")
async def uninstall_tool(name: str) -> dict:
"""Uninstall a tool from PATH via uv tool uninstall."""
config = get_config()
if name not in config.components:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
comp = config.components[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source")
# uv tool uninstall uses the package name from pyproject.toml
source_dir = config.root / comp.source
pkg_name = source_dir.name
pyproject = source_dir / "pyproject.toml"
if pyproject.exists():
import tomllib
with open(pyproject, "rb") as f:
data = tomllib.load(f)
pkg_name = data.get("project", {}).get("name", pkg_name)
proc = await asyncio.create_subprocess_exec(
"uv",
"tool",
"uninstall",
pkg_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
output = (stdout or stderr or b"").decode().strip()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail=output or "Uninstall failed")
return {"component": name, "action": "uninstall", "status": "ok"}
return {
"component": result.component,
"action": result.action,
"status": result.status,
"output": result.output,
}

View File

@@ -71,6 +71,169 @@ class TestComponentDetail:
assert response.status_code == 404
class TestServicesList:
"""GET /services endpoint tests."""
def test_returns_deployed_services(self, client: TestClient) -> None:
"""Returns deployed services from registry."""
response = client.get("/services")
assert response.status_code == 200
data = response.json()
names = [s["id"] for s in data]
assert "test-svc" in names
def test_service_has_port_and_health(self, client: TestClient) -> None:
"""Service summary includes port and health info."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert svc["port"] == 19000
assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc"
assert svc["managed"] is True
def test_no_schedule_field(self, client: TestClient) -> None:
"""ServiceSummary does not have schedule field."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert "schedule" not in svc
def test_no_installed_field(self, client: TestClient) -> None:
"""ServiceSummary does not have installed field."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert "installed" not in svc
def test_excludes_jobs(self, client: TestClient) -> None:
"""Jobs (scheduled items) are not in the services list."""
response = client.get("/services")
data = response.json()
names = [s["id"] for s in data]
assert "test-job" not in names
class TestServiceDetail:
"""GET /services/{name} endpoint tests."""
def test_get_service(self, client: TestClient) -> None:
"""Returns detailed info for a service."""
response = client.get("/services/test-svc")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-svc"
assert "manifest" in data
assert data["manifest"]["runner"] == "python"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown service."""
response = client.get("/services/nonexistent")
assert response.status_code == 404
class TestJobsList:
"""GET /jobs endpoint tests."""
def test_returns_jobs(self, client: TestClient) -> None:
"""Returns jobs from castle.yaml."""
response = client.get("/jobs")
assert response.status_code == 200
data = response.json()
names = [j["id"] for j in data]
assert "test-job" in names
def test_job_has_schedule(self, client: TestClient) -> None:
"""Job summary includes schedule."""
response = client.get("/jobs")
data = response.json()
job = next(j for j in data if j["id"] == "test-job")
assert job["schedule"] == "0 2 * * *"
def test_no_port_field(self, client: TestClient) -> None:
"""JobSummary does not have port field."""
response = client.get("/jobs")
data = response.json()
job = next(j for j in data if j["id"] == "test-job")
assert "port" not in job
def test_excludes_services(self, client: TestClient) -> None:
"""Services (non-scheduled) are not in the jobs list."""
response = client.get("/jobs")
data = response.json()
names = [j["id"] for j in data]
assert "test-svc" not in names
class TestJobDetail:
"""GET /jobs/{name} endpoint tests."""
def test_get_job(self, client: TestClient) -> None:
"""Returns detailed info for a job."""
response = client.get("/jobs/test-job")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-job"
assert "manifest" in data
assert data["schedule"] == "0 2 * * *"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown job."""
response = client.get("/jobs/nonexistent")
assert response.status_code == 404
class TestProgramsList:
"""GET /programs endpoint tests."""
def test_returns_programs(self, client: TestClient) -> None:
"""Returns programs from castle.yaml."""
response = client.get("/programs")
assert response.status_code == 200
data = response.json()
names = [p["id"] for p in data]
assert "test-tool" in names
def test_program_has_behavior(self, client: TestClient) -> None:
"""Program summary includes behavior."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert tool["behavior"] == "tool"
def test_no_port_field(self, client: TestClient) -> None:
"""ProgramSummary does not have port field."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert "port" not in tool
def test_no_schedule_field(self, client: TestClient) -> None:
"""ProgramSummary does not have schedule field."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert "schedule" not in tool
class TestProgramDetail:
"""GET /programs/{name} endpoint tests."""
def test_get_program(self, client: TestClient) -> None:
"""Returns detailed info for a program."""
response = client.get("/programs/test-tool")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-tool"
assert "manifest" in data
assert data["behavior"] == "tool"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown program."""
response = client.get("/programs/nonexistent")
assert response.status_code == 404
class TestGateway:
"""Gateway info endpoint tests."""

View File

@@ -1,7 +1,7 @@
gateway:
port: 9000
components:
programs:
central-context:
description: Content storage API useful to get context into my data dir from anywhere
on the LAN

View File

@@ -7,7 +7,7 @@ import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ComponentSpec,
ProgramSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -52,15 +52,15 @@ def run_create(args: argparse.Namespace) -> int:
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.programs or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
components_dir = config.root / "components"
components_dir.mkdir(exist_ok=True)
project_dir = components_dir / name
programs_dir = config.root / "programs"
programs_dir.mkdir(exist_ok=True)
project_dir = programs_dir / name
if project_dir.exists():
print(f"Error: directory 'components/{name}' already exists")
print(f"Error: directory 'programs/{name}' already exists")
return 1
# Determine port for daemons
@@ -77,17 +77,17 @@ def run_create(args: argparse.Namespace) -> int:
name=name,
package_name=package_name,
stack=stack,
description=args.description or f"A castle {stack} component",
description=args.description or f"A castle {stack} program",
port=port,
)
# Build entries
if behavior == "daemon":
# Component for software identity
config.components[name] = ComponentSpec(
# Program for software identity
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
)
# Service for deployment
@@ -105,31 +105,31 @@ def run_create(args: argparse.Namespace) -> int:
manage=ManageSpec(systemd=SystemdSpec()),
)
elif behavior == "tool":
config.components[name] = ComponentSpec(
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
else:
# frontend or other
config.components[name] = ComponentSpec(
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
description=args.description or f"A castle {stack} program",
source=f"programs/{name}",
stack=stack,
)
save_config(config)
print(f"Created {stack} component '{name}' at {project_dir}")
print(f"Created {stack} program '{name}' at {project_dir}")
if port:
print(f" Port: {port}")
print(" Registered in castle.yaml")
print("\nNext steps:")
print(f" cd components/{name}")
print(f" cd programs/{name}")
print(" uv sync")
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")

View File

@@ -37,16 +37,16 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy components from castle.yaml to ~/.castle/."""
"""Deploy from castle.yaml to ~/.castle/."""
config = load_config()
target_name = getattr(args, "component", None)
target_name = getattr(args, "name", None)
ensure_dirs()
# Build node config
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
# Load existing registry to preserve components not being redeployed,
# Load existing registry to preserve entries not being redeployed,
# or start fresh if deploying all
if target_name and REGISTRY_PATH.exists():
try:
@@ -96,24 +96,24 @@ def run_deploy(args: argparse.Namespace) -> int:
# Reload systemd daemon
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
print(f"\nDeployed {deployed_count} component(s).")
print(f"\nDeployed {deployed_count} item(s).")
print("Run 'castle services start' to start all services.")
return 0
def _env_prefix(name: str) -> str:
"""Derive env var prefix from component name: central-context → CENTRAL_CONTEXT."""
"""Derive env var prefix from name: central-context → CENTRAL_CONTEXT."""
return name.replace("-", "_").upper()
def _resolve_description(
config: CastleConfig, spec: ServiceSpec | JobSpec
) -> str | None:
"""Get description, falling through to component if referenced."""
"""Get description, falling through to program if referenced."""
if spec.description:
return spec.description
if spec.component and spec.component in config.components:
return config.components[spec.component].description
if spec.component and spec.component in config.programs:
return config.programs[spec.component].description
return None
@@ -155,10 +155,10 @@ def _build_deployed_service(
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
# Resolve stack from referenced component
# Resolve stack from referenced program
stack = None
if svc.component and svc.component in config.components:
stack = config.components[svc.component].stack
if svc.component and svc.component in config.programs:
stack = config.programs[svc.component].stack
return DeployedComponent(
runner=run.runner,
@@ -195,10 +195,10 @@ def _build_deployed_job(
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
# Resolve stack from referenced component
# Resolve stack from referenced program
stack = None
if job.component and job.component in config.components:
stack = config.components[job.component].stack
if job.component and job.component in config.programs:
stack = config.programs[job.component].stack
return DeployedComponent(
runner=run.runner,
@@ -275,10 +275,10 @@ def _print_deployed(name: str, deployed: DeployedComponent) -> None:
def _copy_app_static(config: CastleConfig) -> None:
"""Copy castle-app build output to ~/.castle/static/castle-app/."""
if "castle-app" not in config.components:
if "castle-app" not in config.programs:
return
comp = config.components["castle-app"]
comp = config.programs["castle-app"]
if not (comp.build and comp.build.outputs):
return

View File

@@ -3,37 +3,44 @@
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
import asyncio
from castle_core.stacks import get_handler
from castle_cli.config import CastleConfig, load_config
def _get_project_dir(config: CastleConfig, project_name: str) -> Path:
"""Get the directory for a component."""
if project_name not in config.components:
raise ValueError(f"Unknown component: {project_name}")
manifest = config.components[project_name]
working_dir = manifest.source_dir or project_name
return config.root / working_dir
def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
"""Run a stack action for a single project. Returns True on success."""
if project_name not in config.programs:
print(f"Unknown component: {project_name}")
return False
comp = config.programs[project_name]
if not comp.source:
print(f" {project_name}: no source directory, skipping")
return True
def _has_pyproject(project_dir: Path) -> bool:
"""Check if a project directory has a pyproject.toml."""
return (project_dir / "pyproject.toml").exists()
handler = get_handler(comp.stack)
if handler is None:
print(f" {project_name}: unsupported stack '{comp.stack}', skipping")
return True
def _run_in_project(project_dir: Path, cmd: list[str], label: str) -> bool:
"""Run a command in a project directory. Returns True on success."""
if not _has_pyproject(project_dir):
return True # Skip projects without pyproject.toml
method_name = action.replace("-", "_")
method = getattr(handler, method_name, None)
if method is None:
print(f" {project_name}: action '{action}' not supported")
return False
print(f"\n{'' * 40}")
print(f" {label}: {project_dir.name}")
print(f" {action}: {project_name}")
print(f"{'' * 40}")
result = subprocess.run(cmd, cwd=project_dir)
return result.returncode == 0
result = asyncio.run(method(project_name, comp, config.root))
if result.output:
print(result.output)
return result.status == "ok"
def run_test(args: argparse.Namespace) -> int:
@@ -41,34 +48,23 @@ def run_test(args: argparse.Namespace) -> int:
config = load_config()
if args.project:
project_dir = _get_project_dir(config, args.project)
tests_dir = project_dir / "tests"
if not tests_dir.exists():
print(f"No tests directory found for {args.project}")
return 1
success = _run_in_project(
project_dir,
["uv", "run", "pytest", "tests/", "-v"],
"Testing",
)
success = _run_action(config, args.project, "test")
return 0 if success else 1
# Run all
all_passed = True
for name, manifest in config.components.items():
working_dir = manifest.source_dir or name
project_dir = config.root / working_dir
tests_dir = project_dir / "tests"
if not tests_dir.exists():
for name, comp in config.programs.items():
if not comp.source:
continue
if not _has_pyproject(project_dir):
handler = get_handler(comp.stack)
if handler is None:
continue
success = _run_in_project(
project_dir,
["uv", "run", "pytest", "tests/", "-v"],
"Testing",
)
if not success:
# Skip projects without a tests directory (python) or test script (node)
source_dir = config.root / comp.source
if comp.stack in ("python-cli", "python-fastapi"):
if not (source_dir / "tests").exists():
continue
if not _run_action(config, name, "test"):
all_passed = False
if all_passed:
@@ -83,27 +79,18 @@ def run_lint(args: argparse.Namespace) -> int:
config = load_config()
if args.project:
project_dir = _get_project_dir(config, args.project)
success = _run_in_project(
project_dir,
["uv", "run", "ruff", "check", "."],
"Linting",
)
success = _run_action(config, args.project, "lint")
return 0 if success else 1
# Run all
all_passed = True
for name, manifest in config.components.items():
working_dir = manifest.source_dir or name
project_dir = config.root / working_dir
if not _has_pyproject(project_dir):
for name, comp in config.programs.items():
if not comp.source:
continue
success = _run_in_project(
project_dir,
["uv", "run", "ruff", "check", "."],
"Linting",
)
if not success:
handler = get_handler(comp.stack)
if handler is None:
continue
if not _run_action(config, name, "lint"):
all_passed = False
if all_passed:

View File

@@ -1,4 +1,4 @@
"""castle info - show detailed component information."""
"""castle info - show detailed program information."""
from __future__ import annotations
@@ -17,8 +17,8 @@ GREEN = "\033[92m"
RED = "\033[91m"
def _load_deployed_component(name: str) -> object | None:
"""Try to load a specific deployed component from registry."""
def _load_deployed_program(name: str) -> object | None:
"""Try to load a specific deployed program from registry."""
try:
from castle_core.registry import load_registry
@@ -29,23 +29,23 @@ def _load_deployed_component(name: str) -> object | None:
def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a component, service, or job."""
"""Show detailed info for a program, service, or job."""
config = load_config()
name = args.project
# Look up in all sections
component = config.components.get(name)
program = config.programs.get(name)
service = config.services.get(name)
job = config.jobs.get(name)
if not component and not service and not job:
if not program and not service and not job:
print(f"Error: '{name}' not found in castle.yaml")
return 1
deployed = _load_deployed_component(name)
deployed = _load_deployed_program(name)
if getattr(args, "json", False):
return _info_json(config, name, component, service, job, deployed)
return _info_json(config, name, program, service, job, deployed)
# Human-readable output
print(f"\n{BOLD}{name}{RESET}")
@@ -56,51 +56,51 @@ def run_info(args: argparse.Namespace) -> int:
print(f" {BOLD}behavior{RESET}: daemon")
elif job:
print(f" {BOLD}behavior{RESET}: tool")
elif component:
if component.tool or (component.install and component.install.path):
elif program:
if program.tool or (program.install and program.install.path):
print(f" {BOLD}behavior{RESET}: tool")
elif component.build:
elif program.build:
print(f" {BOLD}behavior{RESET}: frontend")
else:
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 program and program.stack:
stack = program.stack
elif service and service.component and service.component in config.programs:
stack = config.programs[service.component].stack
elif job and job.component and job.component in config.programs:
stack = config.programs[job.component].stack
if stack:
print(f" {BOLD}stack{RESET}: {stack}")
# Component info
if component:
if component.description:
print(f" {BOLD}description{RESET}: {component.description}")
if component.source:
print(f" {BOLD}source{RESET}: {component.source}")
if component.install and component.install.path:
pi = component.install.path
# Program info
if program:
if program.description:
print(f" {BOLD}description{RESET}: {program.description}")
if program.source:
print(f" {BOLD}source{RESET}: {program.source}")
if program.install and program.install.path:
pi = program.install.path
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
if component.tool:
t = component.tool
if program.tool:
t = program.tool
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
if component.tags:
print(f" {BOLD}tags{RESET}: {', '.join(component.tags)}")
if program.tags:
print(f" {BOLD}tags{RESET}: {', '.join(program.tags)}")
# Service info
spec = service or job
if spec:
desc = spec.description
if not desc and spec.component and spec.component in config.components:
desc = config.components[spec.component].description
if desc and not (component and component.description == desc):
if not desc and spec.component and spec.component in config.programs:
desc = config.programs[spec.component].description
if desc and not (program and program.description == desc):
print(f" {BOLD}description{RESET}: {desc}")
if spec.component:
print(f" {BOLD}component{RESET}: {spec.component}")
print(f" {BOLD}program{RESET}: {spec.component}")
# Run spec
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
@@ -154,10 +154,10 @@ def run_info(args: argparse.Namespace) -> int:
# Show CLAUDE.md if it exists
source_dir = None
if component and component.source_dir:
source_dir = component.source_dir
elif spec and spec.component and spec.component in config.components:
source_dir = config.components[spec.component].source_dir
if program and program.source_dir:
source_dir = program.source_dir
elif spec and spec.component and spec.component in config.programs:
source_dir = config.programs[spec.component].source_dir
if source_dir:
claude_md = _find_claude_md(config.root, source_dir)
@@ -173,7 +173,7 @@ def run_info(args: argparse.Namespace) -> int:
def _info_json(
config: object,
name: str,
component: object | None,
program: object | None,
service: object | None,
job: object | None,
deployed: object | None,
@@ -181,28 +181,28 @@ def _info_json(
"""Output JSON info."""
data: dict = {"name": name}
if component:
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
if program:
data["program"] = program.model_dump(exclude_none=True, exclude={"id"})
if service:
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
data["behavior"] = "daemon"
if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
data["behavior"] = "tool"
if not service and not job and component:
if component.tool or (component.install and component.install.path):
if not service and not job and program:
if program.tool or (program.install and program.install.path):
data["behavior"] = "tool"
elif component.build:
elif program.build:
data["behavior"] = "frontend"
# Resolve stack
stack = None
if component and component.stack:
stack = component.stack
elif service and service.component and service.component in config.components:
stack = config.components[service.component].stack
elif job and job.component and job.component in config.components:
stack = config.components[job.component].stack
if program and program.stack:
stack = program.stack
elif service and service.component and service.component in config.programs:
stack = config.programs[service.component].stack
elif job and job.component and job.component in config.programs:
stack = config.programs[job.component].stack
if stack:
data["stack"] = stack

View File

@@ -1,4 +1,4 @@
"""castle list - show all registered components."""
"""castle list - show all registered programs, services, and jobs."""
from __future__ import annotations
@@ -50,27 +50,27 @@ def _load_deployed() -> dict[str, object] | None:
def _resolve_stack(config: object, name: str) -> str | None:
"""Resolve stack from component reference or direct component."""
# Check services for component ref
"""Resolve stack from program reference or direct program."""
# Check services for program 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 comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Check jobs for program 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
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Direct program
if name in config.programs:
return config.programs[name].stack
return None
def run_list(args: argparse.Namespace) -> int:
"""List all components, services, and jobs."""
"""List all programs, services, and jobs."""
config = load_config()
deployed = _load_deployed()
@@ -123,12 +123,12 @@ def run_list(args: argparse.Namespace) -> int:
sched = f" {DIM}[{job.schedule}]{RESET}"
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
# Components (tools, frontends, etc.)
# Programs (tools, frontends, etc.)
show_tools = not filter_behavior or filter_behavior == "tool"
show_frontends = not filter_behavior or filter_behavior == "frontend"
if show_tools or show_frontends:
# Collect non-daemon components
# Collect non-daemon programs
comps: dict[str, tuple[str, str | None, str | None]] = {}
if show_tools:
@@ -147,7 +147,7 @@ def run_list(args: argparse.Namespace) -> int:
if comps:
any_output = True
color = CYAN
print(f"\n{BOLD}{color}Components{RESET}")
print(f"\n{BOLD}{color}Programs{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, (behavior, stack, description) in comps.items():
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
@@ -156,7 +156,7 @@ def run_list(args: argparse.Namespace) -> int:
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
if not any_output:
print("No components found.")
print("No programs found.")
if deployed is None:
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")

View File

@@ -28,7 +28,7 @@ def run_sync(args: argparse.Namespace) -> int:
all_ok = True
synced_dirs: set[Path] = set()
for name, comp in config.components.items():
for name, comp in config.programs.items():
source_dir = comp.source_dir
if not source_dir:
continue
@@ -63,7 +63,7 @@ def run_sync(args: argparse.Namespace) -> int:
installed_dirs: set[Path] = set()
# Install components with install.path
for name, comp in config.components.items():
for name, comp in config.programs.items():
if not (comp.install and comp.install.path):
continue
source = comp.source_dir
@@ -77,8 +77,8 @@ def run_sync(args: argparse.Namespace) -> int:
continue
# Find source from component reference
source = None
if svc.component and svc.component in config.components:
source = config.components[svc.component].source_dir
if svc.component and svc.component in config.programs:
source = config.programs[svc.component].source_dir
if not source:
continue
source_dir = config.root / source

View File

@@ -29,7 +29,7 @@ def run_tool(args: argparse.Namespace) -> int:
def _tool_list() -> int:
"""List all registered tools."""
config = load_config()
tools = {k: v for k, v in config.components.items() if v.tool}
tools = {k: v for k, v in config.programs.items() if v.tool}
if not tools:
print("No tools registered.")
@@ -51,11 +51,11 @@ def _tool_list() -> int:
def _tool_info(name: str) -> int:
"""Show detailed info about a tool, including .md documentation."""
config = load_config()
if name not in config.components:
if name not in config.programs:
print(f"Error: '{name}' not found")
return 1
manifest = config.components[name]
manifest = config.programs[name]
if not manifest.tool:
print(f"Error: '{name}' is not a tool")
return 1

View File

@@ -19,7 +19,7 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# castle list
list_parser = subparsers.add_parser("list", help="List all components")
list_parser = subparsers.add_parser("list", help="List all programs, services, and jobs")
list_parser.add_argument(
"--behavior",
choices=["daemon", "tool", "frontend"],
@@ -43,8 +43,8 @@ def build_parser() -> argparse.ArgumentParser:
create_parser.add_argument("--description", default="", help="Project description")
create_parser.add_argument("--port", type=int, help="Port number (daemons only)")
# castle info
info_parser = subparsers.add_parser("info", help="Show component details")
info_parser.add_argument("project", help="Component name")
info_parser = subparsers.add_parser("info", help="Show program details")
info_parser.add_argument("project", help="Program, service, or job name")
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
# castle test
@@ -91,25 +91,25 @@ def build_parser() -> argparse.ArgumentParser:
services_sub.add_parser("stop", help="Stop all services and gateway")
# castle logs
logs_parser = subparsers.add_parser("logs", help="View component logs")
logs_parser.add_argument("name", help="Component name")
logs_parser = subparsers.add_parser("logs", help="View service/job logs")
logs_parser.add_argument("name", help="Service or job name")
logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output")
logs_parser.add_argument(
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
)
# castle run
run_parser = subparsers.add_parser("run", help="Run a component in the foreground")
run_parser.add_argument("name", help="Component name")
run_parser = subparsers.add_parser("run", help="Run a service in the foreground")
run_parser.add_argument("name", help="Service name")
run_parser.add_argument(
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the component"
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the service"
)
# castle deploy
deploy_parser = subparsers.add_parser(
"deploy", help="Deploy components to ~/.castle/ (spec → runtime)"
"deploy", help="Deploy to ~/.castle/ (spec → runtime)"
)
deploy_parser.add_argument("component", nargs="?", help="Component to deploy (default: all)")
deploy_parser.add_argument("name", nargs="?", help="Service or job to deploy (default: all)")
# castle tool
tool_parser = subparsers.add_parser("tool", help="Manage tools")

View File

@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
BuildSpec,
CaddySpec,
Capability,
ComponentSpec,
ProgramSpec,
DefaultsSpec,
EnvMap,
ExposeSpec,

View File

@@ -32,7 +32,7 @@ class TestCreateCommand:
result = run_create(args)
assert result == 0
project_dir = castle_root / "components" / "my-api"
project_dir = castle_root / "programs" / "my-api"
assert project_dir.exists()
assert (project_dir / "pyproject.toml").exists()
assert (project_dir / "src" / "my_api" / "main.py").exists()
@@ -41,8 +41,8 @@ class TestCreateCommand:
assert (project_dir / "tests" / "test_health.py").exists()
assert (project_dir / "CLAUDE.md").exists()
# Verify registered as ComponentSpec + ServiceSpec
assert "my-api" in config.components
# Verify registered as ProgramSpec + ServiceSpec
assert "my-api" in config.programs
assert "my-api" in config.services
svc = config.services["my-api"]
assert svc.expose.http.internal.port == 9050
@@ -64,12 +64,12 @@ class TestCreateCommand:
result = run_create(args)
assert result == 0
project_dir = castle_root / "components" / "my-tool2"
project_dir = castle_root / "programs" / "my-tool2"
assert project_dir.exists()
assert (project_dir / "src" / "my_tool2" / "main.py").exists()
assert (project_dir / "CLAUDE.md").exists()
assert "my-tool2" in config.components
comp = config.components["my-tool2"]
assert "my-tool2" in config.programs
comp = config.programs["my-tool2"]
assert comp.tool is not None
assert comp.install is not None

5793
components/browser/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

8
components/docx-extractor/uv.lock generated Normal file
View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "docx-extractor"
version = "0.1.0"
source = { editable = "." }

View File

@@ -9,7 +9,7 @@ from pathlib import Path
import yaml
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
def find_castle_root() -> Path:
@@ -47,25 +47,25 @@ class CastleConfig:
root: Path
gateway: GatewayConfig
components: dict[str, ComponentSpec]
programs: dict[str, ProgramSpec]
services: dict[str, ServiceSpec]
jobs: dict[str, JobSpec]
@property
def tools(self) -> dict[str, ComponentSpec]:
"""Return components that are tools (have install.path or tool spec)."""
def tools(self) -> dict[str, ProgramSpec]:
"""Return programs that are tools (have install.path or tool spec)."""
return {
k: v
for k, v in self.components.items()
for k, v in self.programs.items()
if (v.install and v.install.path) or v.tool
}
@property
def frontends(self) -> dict[str, ComponentSpec]:
"""Return components that are frontends (have build outputs)."""
def frontends(self) -> dict[str, ProgramSpec]:
"""Return programs that are frontends (have build outputs)."""
return {
k: v
for k, v in self.components.items()
for k, v in self.programs.items()
if v.build and (v.build.outputs or v.build.commands)
}
@@ -94,11 +94,11 @@ def _read_secret(name: str) -> str:
return f"<MISSING_SECRET:{name}>"
def _parse_component(name: str, data: dict) -> ComponentSpec:
"""Parse a components: entry into a ComponentSpec."""
def _parse_program(name: str, data: dict) -> ProgramSpec:
"""Parse a programs: entry into a ProgramSpec."""
data_copy = dict(data)
data_copy["id"] = name
return ComponentSpec.model_validate(data_copy)
return ProgramSpec.model_validate(data_copy)
def _parse_service(name: str, data: dict) -> ServiceSpec:
@@ -130,9 +130,11 @@ def load_config(root: Path | None = None) -> CastleConfig:
gateway_data = data.get("gateway", {})
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
components: dict[str, ComponentSpec] = {}
for name, comp_data in data.get("components", {}).items():
components[name] = _parse_component(name, comp_data)
programs: dict[str, ProgramSpec] = {}
# Support both "programs:" and legacy "components:" key
programs_data = data.get("programs") or data.get("components") or {}
for name, comp_data in programs_data.items():
programs[name] = _parse_program(name, comp_data)
services: dict[str, ServiceSpec] = {}
for name, svc_data in data.get("services", {}).items():
@@ -145,7 +147,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
return CastleConfig(
root=root,
gateway=gateway,
components=components,
programs=programs,
services=services,
jobs=jobs,
)
@@ -187,7 +189,7 @@ _STRUCTURAL_KEYS = {
}
def _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict:
def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict:
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
exclude_fields = {"id"}
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
@@ -228,10 +230,10 @@ def save_config(config: CastleConfig) -> None:
"""Save castle configuration to castle.yaml."""
data: dict = {"gateway": {"port": config.gateway.port}}
if config.components:
data["components"] = {}
for name, spec in config.components.items():
data["components"][name] = _spec_to_yaml_dict(spec)
if config.programs:
data["programs"] = {}
for name, spec in config.programs.items():
data["programs"][name] = _spec_to_yaml_dict(spec)
if config.services:
data["services"] = {}

View File

@@ -1,4 +1,4 @@
"""Castle manifest models — component specs, service specs, job specs."""
"""Castle manifest models — program specs, service specs, job specs."""
from __future__ import annotations
@@ -197,11 +197,11 @@ class DefaultsSpec(BaseModel):
# ---------------------
# Component spec — software identity
# Program spec — software identity
# ---------------------
class ComponentSpec(BaseModel):
class ProgramSpec(BaseModel):
"""Software catalog entry — what exists."""
id: str = ""

View File

@@ -0,0 +1,260 @@
"""Stack protocol — lifecycle actions for each development stack."""
from __future__ import annotations
import asyncio
import os
import shutil
import tomllib
from dataclasses import dataclass
from pathlib import Path
from castle_core.config import STATIC_DIR
from castle_core.manifest import ProgramSpec
DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
INSTALL_ACTIONS = ["install", "uninstall"]
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS
# User-local tool directories that may not be on the systemd service PATH.
_EXTRA_PATH_DIRS = [
Path.home() / ".local" / "share" / "pnpm",
Path.home() / ".local" / "bin",
]
@dataclass
class ActionResult:
"""Result of a stack lifecycle action."""
component: str
action: str
status: str # "ok" | "error"
output: str = ""
def _build_env() -> dict[str, str]:
"""Build a subprocess env with user tool dirs on PATH."""
env = os.environ.copy()
extra = ":".join(str(d) for d in _EXTRA_PATH_DIRS if d.exists())
if extra:
env["PATH"] = extra + ":" + env.get("PATH", "")
return env
async def _run(cmd: list[str], cwd: Path) -> tuple[int, str]:
"""Run a subprocess and return (returncode, combined output)."""
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd,
env=_build_env(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
stdout, _ = await proc.communicate()
return proc.returncode or 0, (stdout or b"").decode()
def _source_dir(comp: ProgramSpec, root: Path) -> Path:
"""Resolve source directory, raising ValueError if absent."""
if not comp.source:
raise ValueError("No source directory")
return root / comp.source
class StackHandler:
"""Base class — subclasses implement each lifecycle action."""
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
async def check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Composite: lint + type-check + test. Runs all, reports first failure."""
for action_fn, action_name in [
(self.lint, "lint"),
(self.type_check, "type-check"),
(self.test, "test"),
]:
result = await action_fn(name, comp, root)
if result.status != "ok":
return ActionResult(
component=name,
action="check",
status="error",
output=f"{action_name} failed:\n{result.output}",
)
return ActionResult(component=name, action="check", status="ok")
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
class PythonHandler(StackHandler):
"""Handler for python-cli and python-fastapi stacks."""
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["uv", "sync"], src)
return ActionResult(
component=name, action="build", status="ok" if rc == 0 else "error", output=output
)
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
if not (src / "tests").exists():
return ActionResult(
component=name, action="test", status="ok",
output="No tests directory found, skipping.",
)
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
return ActionResult(
component=name, action="test", status="ok" if rc == 0 else "error", output=output
)
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
return ActionResult(
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
)
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["uv", "run", "pyright"], src)
return ActionResult(
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
)
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(
["uv", "tool", "install", "--editable", str(src), "--force"], src
)
return ActionResult(
component=name, action="install", status="ok" if rc == 0 else "error", output=output
)
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
pkg_name = src.name
pyproject = src / "pyproject.toml"
if pyproject.exists():
with open(pyproject, "rb") as f:
data = tomllib.load(f)
pkg_name = data.get("project", {}).get("name", pkg_name)
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
return ActionResult(
component=name, action="uninstall", status="ok" if rc == 0 else "error", output=output
)
class ReactViteHandler(StackHandler):
"""Handler for react-vite stack."""
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "build"], src)
return ActionResult(
component=name, action="build", status="ok" if rc == 0 else "error", output=output
)
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "test"], src)
return ActionResult(
component=name, action="test", status="ok" if rc == 0 else "error", output=output
)
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "lint"], src)
return ActionResult(
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
)
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "type-check"], src)
return ActionResult(
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
)
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Build and copy static assets to ~/.castle/static/{name}/."""
result = await self.build(name, comp, root)
if result.status != "ok":
return ActionResult(
component=name, action="install", status="error",
output=f"Build failed:\n{result.output}",
)
src = _source_dir(comp, root)
outputs = comp.build.outputs if comp.build else []
if not outputs:
return ActionResult(
component=name, action="install", status="error",
output="No build outputs configured.",
)
for output_dir in outputs:
src_path = src / output_dir
if src_path.exists():
dest = STATIC_DIR / name
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src_path, dest)
return ActionResult(
component=name, action="install", status="ok",
output=f"Built and deployed to {STATIC_DIR / name}",
)
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Remove static assets from ~/.castle/static/{name}/."""
dest = STATIC_DIR / name
if dest.exists():
shutil.rmtree(dest)
return ActionResult(
component=name, action="uninstall", status="ok",
output=f"Removed {dest}",
)
return ActionResult(
component=name, action="uninstall", status="ok",
output=f"Nothing to remove ({dest} does not exist)",
)
HANDLERS: dict[str, StackHandler] = {
"python-cli": PythonHandler(),
"python-fastapi": PythonHandler(),
"react-vite": ReactViteHandler(),
}
def get_handler(stack: str | None) -> StackHandler | None:
"""Get the handler for a given stack, or None if unsupported."""
if stack is None:
return None
return HANDLERS.get(stack)
def available_actions(comp: ProgramSpec) -> list[str]:
"""Return the list of actions available for a program."""
if not comp.source:
return []
handler = get_handler(comp.stack)
if handler is None:
return []
return list(ALL_ACTIONS)

View File

@@ -11,7 +11,7 @@ from castle_core.config import (
resolve_env_vars,
save_config,
)
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
class TestLoadConfig:
@@ -22,14 +22,14 @@ class TestLoadConfig:
config = load_config(castle_root)
assert isinstance(config, CastleConfig)
assert config.gateway.port == 18000
assert "test-tool" in config.components
assert "test-tool" in config.programs
assert "test-svc" in config.services
assert "test-job" in config.jobs
def test_load_produces_typed_specs(self, castle_root: Path) -> None:
"""Each section produces the correct spec type."""
config = load_config(castle_root)
assert isinstance(config.components["test-tool"], ComponentSpec)
assert isinstance(config.programs["test-tool"], ProgramSpec)
assert isinstance(config.services["test-svc"], ServiceSpec)
assert isinstance(config.jobs["test-job"], JobSpec)
@@ -86,21 +86,21 @@ class TestSaveConfig:
config2 = load_config(castle_root)
assert config2.gateway.port == config.gateway.port
assert set(config2.components.keys()) == set(config.components.keys())
assert set(config2.programs.keys()) == set(config.programs.keys())
assert set(config2.services.keys()) == set(config.services.keys())
assert set(config2.jobs.keys()) == set(config.jobs.keys())
def test_save_adds_component(self, castle_root: Path) -> None:
"""Adding a component and saving persists it."""
config = load_config(castle_root)
config.components["new-lib"] = ComponentSpec(
config.programs["new-lib"] = ProgramSpec(
id="new-lib", description="A new library"
)
save_config(config)
config2 = load_config(castle_root)
assert "new-lib" in config2.components
assert config2.components["new-lib"].description == "A new library"
assert "new-lib" in config2.programs
assert config2.programs["new-lib"].description == "A new library"
def test_preserves_manage_systemd(self, castle_root: Path) -> None:
"""Roundtrip preserves manage.systemd even with all defaults."""

View File

@@ -6,7 +6,7 @@ import pytest
from castle_core.manifest import (
BuildSpec,
CaddySpec,
ComponentSpec,
ProgramSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -24,12 +24,12 @@ from castle_core.manifest import (
)
class TestComponentSpec:
class TestProgramSpec:
"""Tests for component (software catalog) model."""
def test_minimal(self) -> None:
"""Minimal component just needs an id."""
c = ComponentSpec(id="bare")
c = ProgramSpec(id="bare")
assert c.description is None
assert c.source is None
assert c.install is None
@@ -38,7 +38,7 @@ class TestComponentSpec:
def test_tool_component(self) -> None:
"""Component with tool and install specs."""
c = ComponentSpec(
c = ProgramSpec(
id="my-tool",
description="A tool",
source="my-tool/",
@@ -50,7 +50,7 @@ class TestComponentSpec:
def test_frontend_component(self) -> None:
"""Component with build spec."""
c = ComponentSpec(
c = ProgramSpec(
id="my-app",
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
)
@@ -58,12 +58,12 @@ class TestComponentSpec:
def test_source_dir_from_source(self) -> None:
"""source_dir uses source field."""
c = ComponentSpec(id="x", source="components/x/")
c = ProgramSpec(id="x", source="components/x/")
assert c.source_dir == "components/x"
def test_source_dir_none(self) -> None:
"""source_dir returns None when no source available."""
c = ComponentSpec(id="x")
c = ProgramSpec(id="x")
assert c.source_dir is None
@@ -171,7 +171,7 @@ class TestModelSerialization:
def test_dump_component_excludes_none(self) -> None:
"""model_dump with exclude_none drops None fields."""
c = ComponentSpec(id="test", description="Test")
c = ProgramSpec(id="test", description="Test")
data = c.model_dump(exclude_none=True, exclude={"id"})
assert "description" in data
assert "install" not in data

View File

@@ -1,21 +1,21 @@
# Component Registry
# Registry
How castle tracks, configures, and manages components. This is the central
reference for `castle.yaml` structure and the registry architecture.
How castle tracks, configures, and manages programs, services, and jobs.
This is the central reference for `castle.yaml` structure and the registry
architecture.
## castle.yaml
The single source of truth for all components. Lives at the repo root.
Three top-level sections:
The single source of truth. Lives at the repo root. Three top-level sections:
```yaml
gateway:
port: 9000
components:
programs:
my-tool:
description: Does something useful
source: components/my-tool
source: programs/my-tool
install:
path: { alias: my-tool }
tool:
@@ -51,22 +51,22 @@ jobs:
| Section | Purpose | Category |
|---------|---------|----------|
| `components:` | Software catalog — what exists | tool, frontend, component |
| `programs:` | Software catalog — what exists | tool, frontend, program |
| `services:` | Long-running daemons — how they run | service |
| `jobs:` | Scheduled tasks — when they run | job |
Services and jobs can reference a component via `component:` for description
Services and jobs can reference a program via `component:` for description
fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software).
## Component blocks
## Program blocks
Components define **what software exists** — identity, source, tools, builds.
Programs define **what software exists** — identity, source, tools, builds.
### `source` — Where the source lives
```yaml
source: components/my-tool
source: programs/my-tool
```
Relative path from repo root to the project directory.
@@ -92,7 +92,7 @@ tool:
This block provides metadata for `castle tool list` and the dashboard.
It's separate from `install` (which handles PATH registration). The source
directory is set via the top-level `source` field on the component, not here.
directory is set via the top-level `source` field on the program, not here.
### `build` — How to build it
@@ -203,7 +203,7 @@ Castle generates a systemd `.timer` file alongside the `.service` unit.
Jobs also support `run` (required), `manage`, and `defaults` — same
semantics as services.
## Registering a new component
## Registering a new program
### Via `castle create` (recommended)
@@ -211,7 +211,7 @@ semantics as services.
# Service — scaffolds project, assigns port, registers in castle.yaml
castle create my-service --stack python-fastapi --description "Does something"
# Tool — scaffolds under components/
# Tool — scaffolds under programs/
castle create my-tool --stack python-cli --description "Does something"
```
@@ -220,20 +220,20 @@ castle create my-tool --stack python-cli --description "Does something"
Add entries to the appropriate sections of `castle.yaml`:
```yaml
# Tool — only needs a component entry
components:
# Tool — only needs a program entry
programs:
my-tool:
description: Does something useful
source: components/my-tool
source: programs/my-tool
install:
path:
alias: my-tool
# Service — needs both component and service entries
components:
# Service — needs both program and service entries
programs:
my-service:
description: Does something useful
source: components/my-service
source: programs/my-service
services:
my-service:
@@ -257,7 +257,7 @@ services:
```bash
castle create my-service --stack python-fastapi # 1. Scaffold + register
cd components/my-service && uv sync # 2. Install deps
cd programs/my-service && uv sync # 2. Install deps
# ... implement ...
castle test my-service # 3. Run tests
castle service enable my-service # 4. Generate systemd unit, start
@@ -277,10 +277,10 @@ castle service disable my-service # Stop and remove systemd unit
```bash
castle create my-tool --stack python-cli # 1. Scaffold + register
cd components/my-tool && uv sync # 2. Install deps
cd programs/my-tool && uv sync # 2. Install deps
# ... implement ...
castle test my-tool # 3. Run tests
uv tool install --editable components/my-tool/ # 4. Install to PATH
uv tool install --editable programs/my-tool/ # 4. Install to PATH
```
### Job lifecycle
@@ -306,7 +306,7 @@ and a `.timer` file.
| What | Where |
|------|-------|
| Component registry | `castle.yaml` (repo root) |
| Registry | `castle.yaml` (repo root) |
| Service data | `/data/castle/<name>/` |
| Secrets | `~/.castle/secrets/<NAME>` |
| Generated Caddyfile | `~/.castle/generated/Caddyfile` |
@@ -317,7 +317,7 @@ and a `.timer` file.
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ComponentSpec` — software catalog entry (source, install, tool, build)
- `ProgramSpec` — software catalog entry (source, install, tool, build)
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
@@ -325,7 +325,7 @@ The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
Config loading: `core/src/castle_core/config.py``load_config()` parses
castle.yaml into `CastleConfig` with typed `components`, `services`, and
castle.yaml into `CastleConfig` with typed `programs`, `services`, and
`jobs` dicts.
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer

View File

@@ -153,7 +153,7 @@ These map to two files:
**`castle.yaml`** (in the repo, version-controlled) — Three sections:
```yaml
components:
programs:
central-context:
description: Content storage API
source: components/central-context
@@ -185,7 +185,7 @@ jobs:
systemd: {}
```
Components define *what software exists* (identity, source, install, tools).
Programs define *what software exists* (identity, source, install, tools).
Services define *how daemons run* (run config, expose, proxy, systemd).
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).

View File

@@ -314,7 +314,7 @@ uv run ruff format . # Format
## Registering in castle.yaml
```yaml
components:
programs:
my-tool:
description: Does something useful
source: components/my-tool
@@ -326,7 +326,7 @@ components:
Tools with system dependencies declare them in the component:
```yaml
components:
programs:
pdf2md:
description: Convert PDF files to Markdown
source: components/pdf2md
@@ -337,7 +337,7 @@ components:
system_dependencies: [pandoc, poppler-utils]
```
Tools live in the `components:` section. If a tool also runs on a schedule,
Tools live in the `programs:` section. If a tool also runs on a schedule,
add a separate entry in the `jobs:` section referencing the component.
See @docs/component-registry.md for the full registry reference.

View File

@@ -99,7 +99,7 @@ settings = Settings()
Castle passes config via env vars in castle.yaml:
```yaml
components:
programs:
my-service:
description: Does something useful
source: components/my-service

View File

@@ -108,12 +108,12 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
## Registering as a castle component
A frontend component has a `build` spec (produces static output). Register it
in the `components:` section of `castle.yaml`. No `run` block needed if Caddy
in the `programs:` section of `castle.yaml`. No `run` block needed if Caddy
handles serving directly from the build output.
```yaml
# castle.yaml
components:
programs:
my-frontend:
description: Web dashboard
source: components/my-frontend