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:
30
CLAUDE.md
30
CLAUDE.md
@@ -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
12
TODO.md
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
167
app/src/components/ProgramActions.tsx
Normal file
167
app/src/components/ProgramActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const router = createBrowserRouter([
|
||||
element: <ScheduledDetailPage />,
|
||||
},
|
||||
{
|
||||
path: "/components/:name",
|
||||
path: "/programs/:name",
|
||||
element: <ComponentDetailPage />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
BuildSpec,
|
||||
CaddySpec,
|
||||
Capability,
|
||||
ComponentSpec,
|
||||
ProgramSpec,
|
||||
DefaultsSpec,
|
||||
EnvMap,
|
||||
ExposeSpec,
|
||||
|
||||
@@ -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
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
8
components/docx-extractor/uv.lock
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "docx-extractor"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
@@ -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"] = {}
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
260
core/src/castle_core/stacks.py
Normal file
260
core/src/castle_core/stacks.py
Normal 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)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user