Compare commits

..

10 Commits

Author SHA1 Message Date
Paul Payne
1767a0a304 Adds custom domains. 2026-07-12 17:29:34 -07:00
Paul Payne
964226d671 Add stack dependency management
Stacks declare host toolchains (uv/pnpm/node/hugo/deno/psql) that can drift
from what's installed and, worse, from what's on a running service's PATH.
Make those dependencies first-class and visible.

Model (core):
- stacks.ToolRequirement + StackHandler.tools declare each stack's toolchains
  (command, purpose, phase, install_hint); tools_for() is the single source.
- relations synthesizes them as `kind: tool` requirements so functional?/graph
  account for them; checked runtime-env-aware (run-phase tools of a systemd
  service are probed against the service's PATH, not the shell) via a shared
  generators.systemd.runtime_path helper the unit generator also uses, so the
  checker can't drift from the generator. hint_for() makes every unmet
  requirement actionable.
- stack_status: the derived per-stack health the CLI/API/UI all render.
- config: add ~/.deno/bin to USER_TOOL_PATH_DIRS so deno (supabase edge fns)
  is found by services and the check, same as ~/.local/bin and the pnpm dirs.

Surfaces:
- castle stack list|info (new resource) + GET /stacks/status, /stacks/{name}
  (GET /stacks stays a bare name list for the create-form select).
- castle doctor gains a "Stacks & dependencies" section (FAIL for an enabled
  deployment's missing tool, WARN otherwise, unused stacks skipped).
- castle apply preflight warns (advisory, like _acme_preflight) when a tool is
  missing where a service runs; ConvergePanel renders those warnings.
- Dashboard Stacks page: per-stack tool checklist with versions + copyable
  install hints, program links, and verb chips.

Tests: relations (drift + hints), doctor (ok/fail/skip), /stacks endpoints.
2026-07-12 16:04:48 -07:00
Paul Payne
f8e487071e Adds hugo stack. 2026-07-12 14:29:03 -07:00
Paul Payne
a3ff03fc74 app: enhance gateway change detection for Caddyfile routing 2026-07-12 14:09:07 -07:00
Paul Payne
9af37541fb Fixes system map handles. 2026-07-12 14:07:57 -07:00
Paul Payne
2967d1fa5e Add public domain support for gateway routes in Caddyfile generation 2026-07-11 08:30:59 +00:00
Paul Payne
e33c505949 app: rename the System map route /map -> /system
The nav label was already "System" and the page is SystemMap; only the URL
was stale. Move it to /system, add a back-compat redirect from /map, and update
the command-palette navigations. Unrelated graph node type "map" left as-is.
2026-07-07 21:08:58 -07:00
Paul Payne
a9f1e5b099 app: Add-program flow with server-side filesystem picker
Register an existing repo as a program from the /programs page — the web
equivalent of `castle program add`. An inline form with an editable,
autocompleting path bar browses the *server's* filesystem (a browser's own
file dialog only sees the client machine) and also accepts a git URL.

- core: extract the adopt logic (target parsing, stack/command detection,
  ProgramSpec build) into castle_core.adopt so the CLI and API share it; the
  CLI's add.py now delegates to it.
- castle-api: GET /fs/browse (directory listing, per-entry stat errors skipped
  so one unreadable child can't 403 the dir) and POST /programs/adopt.
- app: AddProgramForm + Add-program button on the Programs page; useBrowse /
  useAdoptProgram hooks.

Also tidy ProgramCard: deployments render below the description, and the
deployment name label is hidden when it matches the program title.
2026-07-07 20:51:59 -07:00
Paul Payne
9a8e16143b For real. Tools schema generation and ux tweaks. 2026-07-07 17:51:00 -07:00
Paul Payne
a3d01ca1b3 Tools schema generation and ux tweaks. 2026-07-07 17:48:52 -07:00
61 changed files with 4319 additions and 320 deletions

View File

@@ -145,8 +145,9 @@ castle apply # served at my-frontend.<domain>
```
The gateway serves the build **in place** from `<source>/<root>` — no copy, no
Node process. Stack: **`docs/stacks/react-vite.md`**. Database-backed apps on the
shared Supabase substrate: **`docs/stacks/supabase.md`**.
Node process. Stack: **`docs/stacks/react-vite.md`**. Content-driven static sites
built by Hugo: **`docs/stacks/hugo.md`**. Database-backed apps on the shared
Supabase substrate: **`docs/stacks/supabase.md`**.
### Adopt an existing repo (no stack needed)
@@ -324,6 +325,7 @@ Inspect + drive from the CLI: **`castle mesh status`** / **`castle mesh nodes`**
| Writing FastAPI services | **`docs/stacks/python-fastapi.md`** |
| Writing CLI tools | **`docs/stacks/python-cli.md`** |
| Writing React/Vite frontends | **`docs/stacks/react-vite.md`** |
| Writing Hugo static sites | **`docs/stacks/hugo.md`** |
| Database-backed apps (shared Supabase) | **`docs/stacks/supabase.md`** |
| **Developing Castle itself** (CLI/core/api/app, key files, endpoints) | **`docs/developing-castle.md`** |

View File

@@ -0,0 +1,216 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { X, Folder, ArrowUp, Check, GitBranch } from "lucide-react"
import { useBrowse, useAdoptProgram } from "@/services/api/hooks"
import { TextField } from "./detail/fields"
const IS_GIT_URL = /^(https?:\/\/|git@|ssh:\/\/)|\.git$/
/** Split the typed path into the directory to browse and the trailing partial
* segment to filter by — the autocomplete engine. Typing within a directory only
* changes the filter (client-side, no refetch); crossing a "/" browses the new dir.
* "/data/repos/wid" -> browse "/data/repos", filter "wid"
* "/data/repos/" -> browse "/data/repos", filter ""
* "wid" -> browse the default repos dir, filter "wid" */
function splitPath(raw: string): { dir: string | null; filter: string } {
if (raw === "") return { dir: null, filter: "" }
if (raw.endsWith("/")) return { dir: raw.length > 1 ? raw.slice(0, -1) : "/", filter: "" }
const idx = raw.lastIndexOf("/")
if (idx === -1) return { dir: null, filter: raw }
if (idx === 0) return { dir: "/", filter: raw.slice(1) }
return { dir: raw.slice(0, idx), filter: raw.slice(idx + 1) }
}
/** Adopt an existing repo as a program (the web `castle program add`). Programs
* live on the *server's* filesystem, so the picker browses the server's dirs: type
* a path (autocompletes as you go), navigate the listing, or paste a git URL. */
export function AddProgramForm({
existingNames,
onCancel,
}: {
existingNames: string[]
onCancel: () => void
}) {
const navigate = useNavigate()
const adopt = useAdoptProgram()
// The path bar is the single source of truth — its value is the adopt target.
// Empty browses the server's repos dir (surfaced as the placeholder), so the
// listing is populated from the first render without seeding the field.
const [input, setInput] = useState("")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [error, setError] = useState("")
const target = input.trim()
const isGit = IS_GIT_URL.test(target)
const { dir, filter } = splitPath(target)
const { data: browse, isFetching, error: browseError } = useBrowse(dir, !isGit)
const entries = (browse?.entries ?? []).filter((e) =>
filter ? e.name.toLowerCase().startsWith(filter.toLowerCase()) : true,
)
const suggestedName = target
? target.replace(/\/+$/, "").split("/").pop()!.replace(/\.git$/, "")
: ""
const effectiveName = name.trim() || suggestedName
const nameError =
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
? "lowercase letters, numbers, hyphens"
: effectiveName && existingNames.includes(effectiveName)
? "already exists"
: ""
const submit = async () => {
if (!target || nameError) return
setError("")
try {
const res = await adopt.mutateAsync({
target,
name: name.trim() || undefined,
description: description.trim() || undefined,
})
navigate(`/programs/${res.program}`)
} catch (e: unknown) {
let msg = e instanceof Error ? e.message : String(e)
try {
msg = JSON.parse((e as Error).message).detail ?? msg
} catch {
/* keep msg */
}
setError(msg)
}
}
return (
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Add program</h3>
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
<X size={16} />
</button>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
{error}
</div>
)}
{/* Editable path bar + live directory listing (or a pasted git URL). */}
<div className="border border-[var(--border)] rounded">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-black/20">
<button
onClick={() => browse?.parent && setInput(browse.parent + "/")}
disabled={isGit || !browse?.parent}
title="Up one level"
className="text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30 shrink-0"
>
<ArrowUp size={14} />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
spellCheck={false}
placeholder={browse?.path ? `${browse.path}/ (or a git URL)` : "/data/repos/… or a git URL"}
className="flex-1 min-w-0 bg-transparent text-xs font-mono text-[var(--foreground)] focus:outline-none py-1"
/>
{isFetching && !isGit && <span className="text-[10px] text-[var(--muted)] shrink-0"></span>}
</div>
{!isGit && (
<div className="max-h-56 overflow-y-auto">
{browseError ? (
<p className="text-xs text-red-400 px-3 py-3">
{(() => {
const m = browseError instanceof Error ? browseError.message : ""
try {
return JSON.parse(m).detail ?? "Cannot read directory"
} catch {
return m || "Cannot read directory"
}
})()}
</p>
) : entries.length > 0 ? (
entries.map((e) => (
<div
key={e.path}
className="flex items-center gap-2 px-3 py-1.5 text-sm border-b border-[var(--border)]/50 last:border-0"
>
<button
onClick={() => setInput(e.path + "/")}
className="flex items-center gap-2 min-w-0 flex-1 text-left hover:text-[var(--primary)]"
title="Open"
>
{e.is_git ? (
<GitBranch size={13} className="shrink-0 text-[var(--primary)]" />
) : (
<Folder size={13} className="shrink-0 text-[var(--muted)]" />
)}
<span className="truncate font-mono">{e.name}</span>
</button>
<button
onClick={() => setInput(e.path)}
className={`shrink-0 px-2 py-0.5 text-xs rounded border transition-colors ${
e.is_program
? "border-[var(--primary)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white"
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{target === e.path ? (
<span className="flex items-center gap-1">
<Check size={11} /> picked
</span>
) : (
"pick"
)}
</button>
</div>
))
) : (
<p className="text-xs text-[var(--muted)] px-3 py-3">
{filter ? "No matching sub-directories." : "No sub-directories."}
</p>
)}
</div>
)}
</div>
{target && (
<p className="text-xs text-[var(--muted)]">
Registering <span className="font-mono text-[var(--foreground)]">{target}</span>
{isGit && " (cloned later via castle clone)"}
</p>
)}
<TextField
label="Name"
value={name}
onChange={(v) => setName(v.toLowerCase())}
mono
placeholder={suggestedName || "program-name"}
/>
{nameError && <p className="text-xs text-red-400 -mt-2 ml-28 sm:ml-36">{nameError}</p>}
<TextField label="Description" value={description} onChange={setDescription} />
<div className="flex justify-end gap-2 pt-2">
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
>
Cancel
</button>
<button
onClick={submit}
disabled={!target || !!nameError || adopt.isPending}
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
>
{adopt.isPending ? "Registering…" : "Register"}
</button>
</div>
</div>
)
}

View File

@@ -26,7 +26,7 @@ interface AppItem {
function detailPathFor(kind: string, name: string): string {
if (kind === "job") return `/jobs/${name}`
if (kind === "tool") return `/tools/${name}`
if (kind === "reference") return `/map`
if (kind === "reference") return `/system`
return `/services/${name}`
}
@@ -137,13 +137,13 @@ function PaletteBody({ onClose }: { onClose: () => void }) {
const launch = (a: AppItem) => {
onClose()
if (a.launchUrl) window.open(a.launchUrl, "_blank", "noreferrer")
else if (a.mapNodeId) navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`)
else if (a.mapNodeId) navigate(`/system?focus=${encodeURIComponent(a.mapNodeId)}`)
else navigate(a.detailPath)
}
const goToMap = (a: AppItem) => {
if (!a.mapNodeId) return
onClose()
navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`)
navigate(`/system?focus=${encodeURIComponent(a.mapNodeId)}`)
}
const details = (a: AppItem) => {
onClose()

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { Check, GitCompare, Loader2, Play } from "lucide-react"
import { AlertTriangle, Check, GitCompare, Loader2, Play } from "lucide-react"
import { apiClient } from "@/services/api/client"
import type { ApplyResult } from "@/services/api/hooks"
@@ -86,6 +86,18 @@ export function ConvergePanel() {
</div>
)}
{/* Advisory warnings from the render/preflight (missing stack toolchains,
acme prerequisites, tunnel notes) — surfaced so a service that can't
build or start doesn't fail silently at apply time. */}
{plan?.messages
?.filter((m) => m.startsWith("Warning"))
.map((m) => (
<div key={m} className="mt-2 flex items-start gap-1.5 text-sm text-amber-400">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{m.replace(/^Warning:\s*/, "")}</span>
</div>
))}
{plan && (
<div className="mt-3 space-y-1">
{plan.changed ? (

View File

@@ -6,15 +6,16 @@ import {
ChevronLeft,
ChevronRight,
Clock,
Gauge,
Globe,
KeyRound,
Layers,
LayoutDashboard,
Menu,
Package,
Search,
Server,
Share2,
Map as MapIcon,
SquareCode,
Wrench,
X,
type LucideIcon,
@@ -32,6 +33,7 @@ type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
// "Deployments" parent. Programs (the catalog) stays top-level.
const NAV: (NavLeaf | NavGroup)[] = [
{ to: "/", label: "Overview", icon: LayoutDashboard, end: true },
{ to: "/system", label: "System", icon: Gauge },
{ to: "/gateway", label: "Gateway", icon: Globe },
{
label: "Deployments",
@@ -42,8 +44,8 @@ const NAV: (NavLeaf | NavGroup)[] = [
{ to: "/tools", label: "Tools", icon: Wrench },
],
},
{ to: "/programs", label: "Programs", icon: Package },
{ to: "/map", label: "System Map", icon: MapIcon },
{ to: "/programs", label: "Programs", icon: SquareCode },
{ to: "/stacks", label: "Stacks", icon: Layers },
{ to: "/mesh", label: "Mesh", icon: Share2 },
{ to: "/secrets", label: "Secrets", icon: KeyRound },
]

View File

@@ -45,25 +45,29 @@ export function ProgramCard({
<StackBadge stack={program.stack} />
</div>
{program.description && (
<p className="text-sm text-[var(--muted)] mb-2">{program.description}</p>
)}
{/* A program has no kind of its own — show its deployments (name · kind).
When a deployment shares the program's name (the common 1:1 case) the
name is redundant with the card title, so show just the kind badge.
Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */}
{showDeployments &&
(program.deployments.length > 0 ? (
<div className="flex flex-col gap-1 mb-2">
<div className="flex flex-col gap-1">
{program.deployments.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs">
{d.name !== program.id && (
<span className="font-mono text-[var(--muted)]">{d.name}</span>
)}
<KindBadge kind={d.kind} />
</div>
))}
</div>
) : (
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p>
<p className="text-xs text-[var(--muted)] italic">no deployment</p>
))}
{program.description && (
<p className="text-sm text-[var(--muted)]">{program.description}</p>
)}
</div>
)
}

View File

@@ -5,7 +5,6 @@ import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge"
import { KindBadge } from "./KindBadge"
interface ServiceCardProps {
service: ServiceSummary
@@ -27,7 +26,15 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
>
{service.id}
</Link>
{health ? (
{/* A static site is served from disk by the gateway, so it's inherently
"up" — show a green pill in the same slot as a service's health, rather
than a separate kind badge, so statics and services read consistently. */}
{service.kind === "static" ? (
<span className="inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full bg-green-800/50 text-green-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
static
</span>
) : health ? (
<HealthBadge status={health.status} latency={health.latency_ms} />
) : hasHttp ? (
<HealthBadge status="unknown" />
@@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
</div>
<div className="flex items-center gap-1.5 mb-2">
{/* A static (caddy-served) "service" is distinguished from a systemd one. */}
{service.kind === "static" && <KindBadge kind="static" />}
<StackBadge stack={service.stack} />
</div>

View File

@@ -5,7 +5,7 @@ import { X } from "lucide-react"
import { apiClient } from "@/services/api/client"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField } from "./fields"
import { Field, TextField, PublicHostRadios } from "./fields"
const SELECT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
@@ -57,6 +57,9 @@ export function CreateDeploymentForm({
const [health, setHealth] = useState("/health")
const [proxy, setProxy] = useState(true)
const [isPublic, setIsPublic] = useState(false)
// Optional exact public FQDN (apex / another zone) overriding <name>.<public_domain>.
const [publicHost, setPublicHost] = useState("")
const [publicMode, setPublicMode] = useState<"default" | "custom">("default")
const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState("")
@@ -81,7 +84,14 @@ export function CreateDeploymentForm({
...(description ? { description } : {}),
}
if (kind === "tool") return { ...base, manager: "path" }
if (kind === "static") return { ...base, manager: "caddy", root }
if (kind === "static") {
const cfg: Record<string, unknown> = { ...base, manager: "caddy", root }
if (isPublic) {
cfg.reach = "public"
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
}
return cfg
}
// systemd (service or job)
const cfg: Record<string, unknown> = {
@@ -103,7 +113,10 @@ export function CreateDeploymentForm({
}
}
if (proxy) cfg.proxy = true
if (proxy && isPublic) cfg.public = true
if (proxy && isPublic) {
cfg.public = true
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
}
return cfg
}
@@ -231,6 +244,17 @@ export function CreateDeploymentForm({
</label>
</Field>
)}
{proxy && isPublic && (
<Field label="Public address" hint="Where it's published on the internet.">
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
/>
</Field>
)}
</>
)}
@@ -239,6 +263,7 @@ export function CreateDeploymentForm({
)}
{kind === "static" && (
<>
<TextField
label="Root"
value={root}
@@ -247,6 +272,24 @@ export function CreateDeploymentForm({
mono
placeholder="dist"
/>
<Field label="Public" hint={`A static site is always served at ${gatewayHost(name || "<name>", domain)}. Enable to also publish it to the internet via the Cloudflare tunnel.`}>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
</label>
</Field>
{isPublic && (
<Field label="Public address" hint="Where it's published on the internet.">
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
/>
</Field>
)}
</>
)}
<div className="flex justify-end gap-2 pt-2">

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
@@ -77,6 +77,13 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const [reach, setReach] = useState(
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"),
)
// Optional exact public FQDN (apex or another zone) that overrides the default
// <name>.<public_domain>. Only applies when reach is public; `publicMode` picks
// default vs a custom host, and `publicHost` holds the custom value.
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
const [publicMode, setPublicMode] = useState<"default" | "custom">(
m.public_host ? "custom" : "default",
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const { element: requiresEditor, value: requiresValue } = useRequires(
@@ -111,6 +118,12 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
}
// reach needs a port to route through the gateway; without one it's off.
config.reach = port ? reach : "off"
// public_host only applies to a public service using a custom domain; else
// clear it (send null so the PATCH merge drops any stale override).
config.public_host =
config.reach === "public" && publicMode === "custom" && publicHost.trim()
? publicHost.trim()
: null
}
delete config.proxy
delete config.public
@@ -201,7 +214,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
/>
<Field
label="Reach"
hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`}
hint="How far this service is exposed. off: host:port only. internal: via the gateway. public: also to the internet via the Cloudflare tunnel."
>
<div className="flex items-center gap-1.5">
<select
@@ -210,15 +223,36 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
disabled={!port}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
>
<option value="off">{port ? `localhost:${port} (local)` : "off"}</option>
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option>
<option value="off">off</option>
<option value="internal">internal</option>
<option value="public">public</option>
</select>
{!port && (
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
)}
</div>
</Field>
{port && (
<Field label="Address" hint="Where this service is reachable.">
{reach === "off" && (
<span className="font-mono text-xs text-[var(--muted)]">localhost:{port}</span>
)}
{reach === "internal" && (
<span className="font-mono text-xs text-[var(--muted)]">
{gatewayHost(service.id, domain)}
</span>
)}
{reach === "public" && (
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(service.id, publicDomain)}
/>
)}
</Field>
)}
</>
)}
{requiresEditor}

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
@@ -29,6 +29,11 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const [isPublic, setIsPublic] = useState(
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public",
)
// Optional exact public FQDN (apex or another zone) overriding <name>.<public_domain>.
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
const [publicMode, setPublicMode] = useState<"default" | "custom">(
m.public_host ? "custom" : "default",
)
const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record<string, string>,
)
@@ -46,6 +51,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
config.description = description || null
config.root = root || "dist"
config.reach = isPublic ? "public" : "internal"
// public_host only applies to a public site using a custom domain; else clear
// (null) so the merge drops any stale override.
config.public_host =
isPublic && publicMode === "custom" && publicHost.trim() ? publicHost.trim() : null
delete config.public
config.requires = requiresValue()
const env = merged()
@@ -73,7 +82,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
/>
<Field
label="Reach"
hint={`How far this static site is served. internal: ${gatewayHost(dep.id, domain)}. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)`}
hint="How far this static site is served. internal: via the gateway. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)"
>
<div className="flex items-center gap-1.5">
<select
@@ -81,11 +90,24 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
onChange={(e) => setIsPublic(e.target.value === "public")}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
>
<option value="internal">{`${gatewayHost(dep.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option>
<option value="internal">internal</option>
<option value="public">public</option>
</select>
</div>
</Field>
<Field label="Address" hint="Where this site is reachable.">
{isPublic ? (
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(dep.id, publicDomain)}
/>
) : (
<span className="font-mono text-xs text-[var(--muted)]">{gatewayHost(dep.id, domain)}</span>
)}
</Field>
{requiresEditor}
{envEditor}
<FormFooter

View File

@@ -1,6 +1,10 @@
import { useState } from "react"
import { Loader2 } from "lucide-react"
import type { DeploymentDetail } from "@/types"
import { TextField, FormFooter, useEnvSecrets } from "./fields"
import { apiClient, ApiError } from "@/services/api/client"
import { Field, TextField, FormFooter } from "./fields"
type GenKind = "help" | "deep" | "ai"
interface Props {
tool: DeploymentDetail
@@ -8,30 +12,94 @@ interface Props {
onDelete?: (name: string) => Promise<void>
}
type Obj = Record<string, unknown>
const obj = (v: unknown): Obj => (v as Obj) ?? {}
/** Edit a tool's (path) deployment config. A path deployment has no launcher,
* port, or schedule — only a description and env, plus its manager. */
* port, or schedule — only a description and its `tool_schema` (the neutral
* tool-call definition handed to agents), plus its manager. It has no run env:
* a tool is a CLI on PATH, invoked from a shell with castle out of the loop, so
* `defaults.env` is never applied (deploy.py only wires env for systemd units). */
export function ToolFields({ tool, onSave, onDelete }: Props) {
const m = tool.manifest
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [description, setDescription] = useState((m.description as string) ?? "")
const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record<string, string>,
const [schemaText, setSchemaText] = useState(
m.tool_schema ? JSON.stringify(m.tool_schema, null, 2) : "",
)
const [schemaError, setSchemaError] = useState<string | null>(null)
// Which generate action is in-flight (null = idle) — lets each button show its
// own spinner/label rather than all three reacting to one shared flag.
const [genKind, setGenKind] = useState<GenKind | null>(null)
const generating = genKind !== null
const [validating, setValidating] = useState(false)
const [validation, setValidation] = useState<{ ok: boolean; msg: string } | null>(null)
const validate = async () => {
setValidation(null)
setSchemaError(null)
let parsed: unknown
try {
parsed = JSON.parse(schemaText)
} catch (e) {
setValidation({ ok: false, msg: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` })
return
}
setValidating(true)
try {
const res = await apiClient.post<{ valid: boolean; errors: string[] }>(
"/config/tools/schema/validate",
parsed,
)
setValidation(
res.valid
? { ok: true, msg: "Schema is valid." }
: { ok: false, msg: res.errors.join(" · ") },
)
} catch (e) {
setValidation({ ok: false, msg: e instanceof ApiError ? e.message : String(e) })
} finally {
setValidating(false)
}
}
const generate = async (kind: GenKind) => {
setGenKind(kind)
setSchemaError(null)
setValidation(null)
try {
const params = new URLSearchParams()
if (kind === "deep") params.set("deep", "true")
if (kind === "ai") params.set("assist", "llm")
const qs = params.toString()
const res = await apiClient.post<{ schema: unknown }>(
`/config/tools/${tool.id}/schema${qs ? `?${qs}` : ""}`,
)
setSchemaText(JSON.stringify(res.schema, null, 2))
} catch (e) {
setSchemaError(e instanceof ApiError ? e.message : String(e))
} finally {
setGenKind(null)
}
}
const handleSave = async () => {
// Validate the (optional) tool_schema JSON before saving.
let parsedSchema: unknown = undefined
if (schemaText.trim()) {
try {
parsedSchema = JSON.parse(schemaText)
} catch (e) {
setSchemaError(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`)
return
}
}
setSchemaError(null)
setSaving(true)
setSaved(false)
try {
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
delete config.id
config.description = description || undefined
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).env
config.tool_schema = parsedSchema ?? null // null clears (PATCH semantics)
await onSave(tool.id, config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
@@ -43,7 +111,88 @@ export function ToolFields({ tool, onSave, onDelete }: Props) {
return (
<div className="space-y-4">
<TextField label="Description" value={description} onChange={setDescription} />
{envEditor}
<Field
label="Tool schema"
hint="Neutral tool-call definition ({name, description, parameters}) handed to agents — rendered to OpenAI or Anthropic on read. Generate it from the tool's --help, then edit freely. Leave empty for none."
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => generate("help")}
disabled={generating}
className="px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
>
{genKind === "help" ? "Generating…" : "Generate from --help"}
</button>
<button
onClick={() => generate("deep")}
disabled={generating}
className="px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
title="Also walk subcommands"
>
Deep
</button>
<button
onClick={() => generate("ai")}
disabled={generating}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
title="Use the LLM to structure subcommand trees the parser can't (requires LLM assist enabled)"
>
{genKind === "ai" ? (
<>
<Loader2 size={12} className="animate-spin" /> Generating
</>
) : (
<> Generate with AI</>
)}
</button>
<button
onClick={validate}
disabled={validating || generating || !schemaText.trim()}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
title="Deterministically check the schema in the box is a valid tool-call definition"
>
{validating ? (
<>
<Loader2 size={12} className="animate-spin" /> Validating
</>
) : (
"Validate"
)}
</button>
{schemaText && (
<button
onClick={() => {
setSchemaText("")
setSchemaError(null)
setValidation(null)
}}
className="text-xs text-red-400 hover:text-red-300"
>
Clear
</button>
)}
</div>
<textarea
value={schemaText}
onChange={(e) => {
setSchemaText(e.target.value)
setValidation(null)
}}
spellCheck={false}
rows={schemaText ? 12 : 3}
placeholder="{ } — generate from --help, or paste an Anthropic tool definition"
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
{validation && (
<p className={`text-xs ${validation.ok ? "text-green-400" : "text-amber-400"}`}>
{validation.ok ? "✓ " : "✗ "}
{validation.msg}
</p>
)}
{schemaError && <p className="text-xs text-red-400">{schemaError}</p>}
</div>
</Field>
<FormFooter
saving={saving}
saved={saved}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"
import { Trash2 } from "lucide-react"
import { Trash2, X } from "lucide-react"
import { SecretsEditor } from "@/components/SecretsEditor"
import { ConfirmModal } from "@/components/ConfirmModal"
@@ -55,6 +55,53 @@ export function TextField({
)
}
/** The public-address sub-control shown when a deployment's reach is `public`:
* a default/custom choice. `default` uses `<name>.<public_domain>` (shown
* read-only); `custom` reveals a text input for an exact FQDN (apex or another
* zone) that becomes the deployment's `public_host`. */
export function PublicHostRadios({
mode,
onModeChange,
value,
onChange,
defaultHost,
}: {
mode: "default" | "custom"
onModeChange: (m: "default" | "custom") => void
value: string
onChange: (v: string) => void
defaultHost: string
}) {
return (
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" checked={mode === "default"} onChange={() => onModeChange("default")} />
<span className="font-mono text-xs text-[var(--muted)]">
{defaultHost} <span className="opacity-60">· default</span>
</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" checked={mode === "custom"} onChange={() => onModeChange("custom")} />
<span className="text-sm">custom domain</span>
</label>
{mode === "custom" && (
<div className="ml-6 space-y-1">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="example.com"
className={`w-64 ${INPUT} font-mono`}
/>
<p className="text-xs text-[var(--muted)] leading-snug">
An exact hostname an apex (example.com) or a name in another zone. The
Cloudflare token(s) need DNS:Edit on that zone.
</p>
</div>
)}
</div>
)
}
// A value that is *exactly* a secret ref → fully editable via SecretsEditor.
const SECRET_RE = /^\$\{secret:([^}]+)\}$/
// A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These
@@ -89,6 +136,26 @@ export function useEnvSecrets(initial: Record<string, string>) {
const [env, setEnv] = useState<Record<string, string>>(plain)
const [secrets, setSecrets] = useState<Record<string, string>>(secretRefs)
// Inline "add variable" row (replaces a native prompt). `adding` toggles it;
// the key is committed only when non-blank and not already an env/secret name.
const [adding, setAdding] = useState(false)
const [newKey, setNewKey] = useState("")
const [newVal, setNewVal] = useState("")
const trimmedKey = newKey.trim()
const duplicate = trimmedKey !== "" && (trimmedKey in env || trimmedKey in secrets)
const canCommit = trimmedKey !== "" && !duplicate
const cancelNew = () => {
setAdding(false)
setNewKey("")
setNewVal("")
}
const commitNew = () => {
if (!canCommit) return
setEnv((p) => ({ ...p, [trimmedKey]: newVal }))
cancelNew()
}
const merged = (): Record<string, string> => {
const out: Record<string, string> = { ...env }
for (const [k, name] of Object.entries(secrets)) out[k] = `\${secret:${name}}`
@@ -134,15 +201,60 @@ export function useEnvSecrets(initial: Record<string, string>) {
</button>
</div>
))}
<button
onClick={() => {
const key = prompt("Variable name:")
if (key) setEnv((p) => ({ ...p, [key]: "" }))
{adding ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<input
autoFocus
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitNew()
if (e.key === "Escape") cancelNew()
}}
placeholder="VARIABLE_NAME"
className={`w-24 sm:w-56 min-w-0 ${INPUT} text-xs font-mono`}
/>
<span className="text-[var(--muted)] shrink-0">=</span>
<input
value={newVal}
onChange={(e) => setNewVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitNew()
if (e.key === "Escape") cancelNew()
}}
placeholder="value"
className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`}
/>
<button
onClick={commitNew}
disabled={!canCommit}
className="px-2 py-1 text-xs rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 shrink-0"
>
Add
</button>
<button
onClick={cancelNew}
className="text-[var(--muted)] hover:text-[var(--foreground)] p-0.5 shrink-0"
title="Cancel"
>
<X size={12} />
</button>
</div>
{duplicate && (
<p className="text-xs text-amber-400">
<span className="font-mono">{trimmedKey}</span> already exists.
</p>
)}
</div>
) : (
<button
onClick={() => setAdding(true)}
className="text-xs text-[var(--primary)] hover:underline"
>
+ Add variable
</button>
)}
</div>
</Field>
<Field label="Secrets">

View File

@@ -1,17 +1,41 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { usePrograms } from "@/services/api/hooks"
import { ProgramList } from "@/components/ProgramList"
import { MonorepoBanner } from "@/components/MonorepoBanner"
import { PageHeader } from "@/components/PageHeader"
import { AddProgramForm } from "@/components/AddProgramForm"
export function Programs() {
const { data: programs, isLoading } = usePrograms()
const [adding, setAdding] = useState(false)
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<PageHeader title="Programs" subtitle="Software catalog" />
<PageHeader
title="Programs"
subtitle="Software catalog"
actions={
<button
onClick={() => setAdding((a) => !a)}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
>
<Plus size={14} /> Add program
</button>
}
/>
<MonorepoBanner />
{adding && (
<div className="mb-6 max-w-2xl">
<AddProgramForm
existingNames={(programs ?? []).map((p) => p.id)}
onCancel={() => setAdding(false)}
/>
</div>
)}
{isLoading ? (
<p className="text-[var(--muted)]">Loading...</p>
) : programs && programs.length > 0 ? (

158
app/src/pages/Stacks.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { useState } from "react"
import { Link } from "react-router-dom"
import { Check, Copy, Layers, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useStacksStatus, type StackStatus, type ToolStatus } from "@/services/api/hooks"
import { PageHeader } from "@/components/PageHeader"
// A stack's overall health pill: green when every tool it needs is present, red
// when one is missing, grey when nothing on this node uses it yet.
function StackBadge({ stack }: { stack: StackStatus }) {
const [label, cls] = !stack.in_use
? (["unused", "bg-gray-700/50 text-gray-400"] as const)
: stack.ok
? (["ready", "bg-green-800/50 text-green-300"] as const)
: (["missing tools", "bg-red-800/50 text-red-300"] as const)
return (
<span className={cn("inline-flex items-center text-xs px-2 py-0.5 rounded-full", cls)}>
{label}
</span>
)
}
// A copy-to-clipboard chip for a tool's install command — the "diagnose + copyable
// hint" contract: we never install for you, but the fix is one click from your shell.
function CopyHint({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
onClick={() => {
void navigator.clipboard?.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
}}
className="group inline-flex items-center gap-1.5 rounded border border-[var(--border)] bg-black/20 px-2 py-1 font-mono text-xs text-[var(--muted)] hover:border-[var(--primary)] hover:text-[var(--foreground)] transition-colors"
title="Copy install command"
>
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
<span className="truncate">{text}</span>
</button>
)
}
function ToolRow({ tool }: { tool: ToolStatus }) {
return (
<div className="flex flex-col gap-1 py-1.5">
<div className="flex items-center gap-2 text-sm">
{tool.present ? (
<Check size={14} className="shrink-0 text-green-400" />
) : (
<X size={14} className="shrink-0 text-red-400" />
)}
<span className="font-mono font-medium">{tool.command}</span>
<span className="text-xs text-[var(--muted)]">
{tool.purpose} · {tool.phase}
</span>
{tool.version && (
<span className="ml-auto truncate max-w-[45%] text-xs text-[var(--muted)]" title={tool.version}>
{tool.version}
</span>
)}
</div>
{!tool.present && (
<div className="pl-6">
<CopyHint text={tool.install_hint} />
</div>
)}
</div>
)
}
function StackCard({ stack }: { stack: StackStatus }) {
return (
<div
className={cn(
"rounded-lg border bg-[var(--card)] p-4",
stack.in_use ? "border-[var(--border)]" : "border-[var(--border)] opacity-60",
)}
>
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold">{stack.name}</h2>
<StackBadge stack={stack} />
</div>
<div className="mt-3 divide-y divide-[var(--border)]">
{stack.tools.length > 0 ? (
stack.tools.map((t) => <ToolRow key={t.command} tool={t} />)
) : (
<p className="py-1.5 text-sm text-[var(--muted)]">No host tools required.</p>
)}
</div>
{stack.programs.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[var(--muted)]">used by</span>
{stack.programs.map((p) => (
<Link
key={p}
to={`/programs/${p}`}
className="rounded bg-black/20 px-1.5 py-0.5 font-mono hover:text-[var(--primary)]"
>
{p}
</Link>
))}
</div>
)}
{stack.verbs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-[var(--muted)]">
{stack.verbs.map((v) => (
<span key={v} className="rounded-full border border-[var(--border)] px-1.5 py-0.5">
{v}
</span>
))}
</div>
)}
</div>
)
}
export function Stacks() {
const { data: stacks, isLoading } = useStacksStatus()
const missing = (stacks ?? []).filter((s) => s.in_use && !s.ok).length
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<PageHeader
title="Stacks"
subtitle="The toolchains each stack needs, and whether they're present where your services run"
actions={
missing > 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-red-400">
<X size={14} /> {missing} stack{missing !== 1 ? "s" : ""} missing tools
</span>
) : stacks && stacks.length > 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-green-400">
<Check size={14} /> all toolchains present
</span>
) : null
}
/>
{isLoading ? (
<p className="text-[var(--muted)]">Loading...</p>
) : stacks && stacks.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{stacks.map((s) => (
<StackCard key={s.name} stack={s} />
))}
</div>
) : (
<div className="flex flex-col items-center gap-2 py-16 text-[var(--muted)]">
<Layers size={28} />
<p>No stacks.</p>
</div>
)}
</div>
)
}

View File

@@ -198,10 +198,13 @@ function MapNode({ data }: NodeProps) {
</div>
{d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>}
</div>
{/* Right handle: drag OUT to connect — to a hub (expose) or another node
(declare "requires"). Right target lands incoming dependency lines. */}
{/* Right handles: `rs` (source) is what you drag OUT to connect — to a hub
(expose) or another node (declare "requires"). `rt` (target) only anchors
incoming dependency lines, so it's nudged below `rs`: stacked on top it
would swallow the pointer-down (you can't START a drag from a target
handle in strict mode) and dragging a fresh line would do nothing. */}
<Handle id="rs" type="source" position={Position.Right} style={{ ...HANDLE, opacity: 0.9, background: color }} />
<Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0 }} />
<Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0, top: "78%" }} />
<Handle id="lt" type="target" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
<Handle id="ls" type="source" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
</div>
@@ -474,9 +477,13 @@ export function SystemMapPage() {
// fallback, but there's no ProgramSpec, so no grip/link.
const catalog = new Set((programs ?? []).map((p) => p.id))
const routes = gateway?.routes ?? []
const exposed = new Set(routes.map((r) => r.name).filter(Boolean) as string[])
const publicSet = new Set(
routes.filter((r) => r.public_url).map((r) => r.name).filter(Boolean) as string[],
// The gateway's authoritative public (tunnel) URL per exposed name, so a public
// service's launch link reads as its internet address, not the internal one.
// (Only the *address* comes from the gateway; exposure state is `n.reach`.)
const publicUrlOf = new Map(
routes
.filter((r) => r.public_url && r.name)
.map((r) => [r.name as string, r.public_url as string]),
)
// Per-node lookups from the graph itself (authoritative for sockets/reach now).
@@ -487,11 +494,15 @@ export function SystemMapPage() {
// http-exposed service served at <name>.<domain>. TCP/tools/jobs aren't.
const domain = gateway?.domain ?? null
const launchOf = (n: (typeof graph.nodes)[number]): string | undefined => {
if (!domain || !n.reach || n.reach === "off") return undefined
if (n.kind === "static") return `https://${n.name}.${domain}`
if (n.kind === "service" && n.endpoints.some((e) => e.protocol === "http"))
return `https://${n.name}.${domain}`
return undefined
if (!n.reach || n.reach === "off") return undefined
const isHttp =
n.kind === "static" ||
(n.kind === "service" && n.endpoints.some((e) => e.protocol === "http"))
if (!isHttp) return undefined
// Public services live at both URLs; prefer the internet one so the link
// matches the green Internet edge instead of reading as internal-only.
if (n.reach === "public" && publicUrlOf.has(n.name)) return publicUrlOf.get(n.name)
return domain ? `https://${n.name}.${domain}` : undefined
}
// Remote launch: <subdomain>.<node-domain>, using the node's own acme domain
@@ -745,17 +756,19 @@ export function SystemMapPage() {
}
}
// Reach is modal: one line per exposed node. public → a single green line to
// Internet; internal → a single blue line to Gateway. Dragging a new line to
// the other target switches the mode; deleting the line removes exposure.
// Reach is modal: one line per exposed node, driven by the node's own `reach`
// (the single source of truth). public → a single green line to Internet;
// internal → a single blue line to Gateway. Dragging a new line to the other
// target switches the mode; deleting the line removes exposure.
const reachOf: Record<string, "internal" | "public"> = {}
for (const name of exposed) {
if (!present.has(name)) continue
const isPub = publicSet.has(name)
reachOf[name] = isPub ? "public" : "internal"
for (const n of graph.nodes) {
if (!present.has(n.name)) continue
if (n.reach !== "internal" && n.reach !== "public") continue
const isPub = n.reach === "public"
reachOf[n.name] = isPub ? "public" : "internal"
edges.push({
id: `exp:${name}`,
source: name,
id: `exp:${n.name}`,
source: n.name,
target: isPub ? "__internet__" : "__gateway__",
sourceHandle: "rs",
targetHandle: "lt",
@@ -987,7 +1000,7 @@ export function SystemMapPage() {
setFocus(picks.length === 1 ? picks[0].id : null)
}, [])
// "Go to on map" from the command palette (/map?focus=<id>): select + center it.
// "Go to on map" from the command palette (/system?focus=<id>): select + center it.
useEffect(() => {
const f = searchParams.get("focus")
if (!f) return

View File

@@ -1,9 +1,10 @@
import { createBrowserRouter } from "react-router-dom"
import { createBrowserRouter, Navigate } from "react-router-dom"
import { Layout } from "@/components/Layout"
import { Overview } from "@/pages/Overview"
import { Services } from "@/pages/Services"
import { Scheduled } from "@/pages/Scheduled"
import { Tools } from "@/pages/Tools"
import { Stacks } from "@/pages/Stacks"
import { Programs } from "@/pages/Programs"
import { GatewayPage } from "@/pages/GatewayPage"
import { MeshPage } from "@/pages/MeshPage"
@@ -25,11 +26,14 @@ export const router = createBrowserRouter([
{ path: "services", element: <Services /> },
{ path: "scheduled", element: <Scheduled /> },
{ path: "tools", element: <Tools /> },
{ path: "stacks", element: <Stacks /> },
{ path: "programs", element: <Programs /> },
{ path: "gateway", element: <GatewayPage /> },
{ path: "mesh", element: <MeshPage /> },
{ path: "secrets", element: <SecretsPage /> },
{ path: "map", element: <SystemMapPage /> },
{ path: "system", element: <SystemMapPage /> },
// Back-compat: the page was formerly at /map. Redirect old links/bookmarks.
{ path: "map", element: <Navigate to="/system" replace /> },
{ path: "services/:name", element: <ServiceDetailPage /> },
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
{ path: "tools/:name", element: <ToolDetailPage /> },

View File

@@ -69,6 +69,36 @@ export function useStacks() {
})
}
// A host toolchain a stack needs + whether it's present where its programs run.
export interface ToolStatus {
command: string
purpose: string
phase: "run" | "build" | "both"
present: boolean
install_hint: string
version: string | null
}
// A stack's dependency health — powers the Stacks page.
export interface StackStatus {
name: string
tools: ToolStatus[]
programs: string[]
deployments: string[]
verbs: string[]
has_enabled_deployment: boolean
in_use: boolean
ok: boolean
}
// Per-stack toolchain health (tools present-where-needed + who uses each stack).
export function useStacksStatus() {
return useQuery({
queryKey: ["stacks", "status"],
queryFn: () => apiClient.get<StackStatus[]>("/stacks/status"),
})
}
export function useJob(name: string) {
return useQuery({
queryKey: ["jobs", name],
@@ -385,6 +415,56 @@ export function useProgramSync() {
})
}
// Server-side directory browser for the "Add program" flow. Programs live on the
// server's filesystem, so the picker browses the server's dirs (the browser's own
// file dialog only sees the client machine). `path` null => the repos dir.
export interface BrowseEntry {
name: string
path: string
is_program: boolean
is_git: boolean
}
export interface BrowseResult {
path: string
parent: string | null
repos_dir: string
entries: BrowseEntry[]
}
export function useBrowse(path: string | null, enabled = true) {
return useQuery({
queryKey: ["browse", path ?? "@repos"],
queryFn: () =>
apiClient.get<BrowseResult>(
`/fs/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`,
),
enabled,
})
}
export interface AdoptResult {
ok: boolean
program: string
source: string
stack: string | null
repo: string | null
commands: string[]
is_git_url: boolean
}
// Adopt an existing repo as a program (the web `castle program add`). Refreshes
// the catalog + the derived graph/repo views.
export function useAdoptProgram() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { target: string; name?: string; description?: string }) =>
apiClient.post<AdoptResult>("/programs/adopt", body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["programs"] })
qc.invalidateQueries({ queryKey: ["graph"] })
qc.invalidateQueries({ queryKey: ["repos"] })
},
})
}
export function useRepos() {
return useQuery({
queryKey: ["repos"],

View File

@@ -20,6 +20,15 @@ class Settings(BaseSettings):
nats_token: str | None = None
mdns_enabled: bool = False
# LLM assist (off by default). Calls the litellm proxy's OpenAI-compatible
# /chat/completions to generate a tool-call schema for CLIs the deterministic
# parser can't structure (subcommand trees). The key is NOT stored here — it's
# read from the secret backend by name at call time.
llm_enabled: bool = False
llm_base_url: str = "https://litellm.civil.payne.io/v1"
llm_model: str = "qwen"
llm_api_key_secret: str = "LITELLM_MASTER_KEY"
model_config = {
"env_prefix": "CASTLE_API_",
"env_file": ".env",

View File

@@ -6,7 +6,7 @@ import asyncio
from pathlib import Path
import yaml
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Body, HTTPException, status
from pydantic import BaseModel
from castle_core.config import (
@@ -454,6 +454,80 @@ def delete_tool(name: str) -> dict:
return _delete_deployment(name, kind="tool")
@router.post("/tools/{name}/schema")
async def generate_tool_schema(
name: str, deep: bool = False, assist: str | None = None
) -> dict:
"""Generate a *draft* tool-call schema (neutral core) from the tool's ``--help``.
Not saved — the client reviews/edits it and persists via ``PUT
/config/tools/{name}`` (the schema rides in the deployment config as
``tool_schema``). Two modes:
* default (``assist`` unset) — deterministic: parse ``--help``. ``deep`` walks
subcommands. 422 if the tool isn't installed / emits no help.
* ``assist=llm`` — send the recursive ``--help`` to the litellm proxy for a
structured schema (the escape hatch for subcommand trees the parser can only
render as a ``command`` string). 503 if LLM assist is disabled; 502 on an
upstream/validation failure.
"""
from castle_core.tool_schema import (
ToolSchemaError,
collect_tool_help,
derive_tool_schema,
)
config = get_config()
if config.deployment("tool", name) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool '{name}' not found",
)
if assist == "llm":
from castle_api.config import settings
from castle_api.llm import LLMAssistError, generate_tool_schema_llm
if not settings.llm_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LLM assist is disabled (set CASTLE_API_LLM_ENABLED=true).",
)
try:
help_text = collect_tool_help(config, name)
except ToolSchemaError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
)
try:
schema = await generate_tool_schema_llm(help_text, name)
except LLMAssistError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)
)
return {"ok": True, "schema": schema, "assist": "llm"}
try:
schema = derive_tool_schema(config, name, deep=deep)
except ToolSchemaError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
)
return {"ok": True, "schema": schema}
@router.post("/tools/schema/validate")
def validate_tool_schema_endpoint(core: dict = Body(...)) -> dict:
"""Deterministically validate a tool-call schema core (no LLM) — the shape and
that ``parameters`` is a valid JSON Schema. Lets the UI check a hand-edited
schema. Returns ``{valid, errors}``."""
from castle_core.tool_schema import validate_tool_schema_core
errors = validate_tool_schema_core(core)
return {"valid": not errors, "errors": errors}
@router.put("/static/{name}")
def save_static(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update the *static* frontend named `name`."""

View File

@@ -0,0 +1,159 @@
"""LLM assist — generate a tool-call schema from a CLI's --help via the litellm proxy.
castle's first LLM-backed feature. The deterministic parser
(``castle_core.tool_schema``) falls back to a single ``command`` string for
subcommand trees (git, castle); this asks an LLM to read the recursive ``--help``
and produce a *structured* neutral tool-call core instead.
Goes through castle's litellm proxy over its OpenAI-compatible
``/chat/completions`` (model-agnostic; the fleet standardizes on litellm), using
forced tool-calling to constrain the output. Every response is validated
deterministically (``validate_tool_schema_core``); on failure the errors are fed
back to the model to repair, up to a cap — so a weak model's malformed draft is
fixed rather than surfaced. The result is a reviewed draft — never auto-saved.
"""
from __future__ import annotations
import json
import httpx
from castle_core.config import read_secret
from castle_core.tool_schema import validate_tool_schema_core
from castle_api.config import settings
_TIMEOUT = 60.0
# Total attempts = 1 initial + repairs. Sonnet is valid first try; the extra
# rounds salvage weaker models (repairing a bad enum, etc.).
_MAX_ATTEMPTS = 3
_SYSTEM = (
"You convert a command-line tool's --help output into a tool-call schema for "
"an AI agent. Call the `emit_tool_schema` function exactly once. Produce a "
"JSON Schema in `parameters` (type: object) whose properties let an agent "
"invoke the tool: one property per meaningful option or subcommand path, each "
"typed (boolean for flags, string for valued options, enum as a LIST of the "
"allowed values for fixed choices) with a concise description drawn from the "
"help. For a tool with subcommands, include a `subcommand` property (an enum "
"listing the available subcommands) plus the important shared options. `name` "
"must match ^[a-zA-Z0-9_-]{1,64}$. Do not invent options not in the help."
)
_EMIT_TOOL = {
"type": "function",
"function": {
"name": "emit_tool_schema",
"description": "Emit the structured tool-call definition for this CLI.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Sanitized tool name."},
"description": {
"type": "string",
"description": "One-line description of what the tool does.",
},
"parameters": {
"type": "object",
"description": (
"A JSON Schema object (type: object with a properties map) "
"describing the tool's invocation arguments."
),
},
},
"required": ["name", "description", "parameters"],
},
},
}
class LLMAssistError(Exception):
"""The LLM assist couldn't produce a valid schema (config, upstream, or output)."""
def _extract_args(data: dict) -> dict | None:
"""Pull the emit_tool_schema arguments out of a chat-completions response.
Returns the parsed arguments dict (even if not yet valid — the caller
validates), or None if the model didn't call the function / returned non-JSON.
"""
try:
call = data["choices"][0]["message"]["tool_calls"][0]
args = call["function"]["arguments"]
except (KeyError, IndexError, TypeError):
return None
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
return None
return args if isinstance(args, dict) else None
async def _complete(messages: list[dict], key: str) -> dict:
"""One forced-tool-call chat completion against the litellm proxy."""
payload = {
"model": settings.llm_model,
"messages": messages,
"tools": [_EMIT_TOOL],
"tool_choice": {"type": "function", "function": {"name": "emit_tool_schema"}},
}
url = f"{settings.llm_base_url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {key}"}
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
try:
resp = await client.post(url, json=payload, headers=headers)
except httpx.HTTPError as e:
raise LLMAssistError(f"litellm request failed: {e}") from e
if resp.status_code >= 400:
raise LLMAssistError(f"litellm returned {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _repair_message(prior: dict, errors: list[str]) -> dict:
"""A user turn that shows the invalid schema + its errors and asks for a fix."""
return {
"role": "user",
"content": (
"Your previous emit_tool_schema output failed validation:\n"
+ "\n".join(f"- {e}" for e in errors)
+ "\n\nHere is what you returned:\n"
+ json.dumps(prior, indent=2)
+ "\n\nCall emit_tool_schema again with every problem fixed. Remember: "
"`enum` must be a list of the allowed values, not a count."
),
}
async def generate_tool_schema_llm(help_text: str, name: str) -> dict:
"""Ask the LLM for a structured neutral tool-call core, repairing invalid
output against ``validate_tool_schema_core`` until it passes (up to
``_MAX_ATTEMPTS``). Raises ``LLMAssistError`` on config/upstream failure or if
it can't be made valid."""
key = read_secret(settings.llm_api_key_secret)
if not key:
raise LLMAssistError(
f"LLM assist enabled but secret '{settings.llm_api_key_secret}' is unset."
)
base = [
{"role": "system", "content": _SYSTEM},
{"role": "user", "content": f"Tool name: {name}\n\n--- help ---\n{help_text}"},
]
messages = list(base)
errors = ["model did not call emit_tool_schema with JSON arguments"]
for _attempt in range(_MAX_ATTEMPTS):
data = await _complete(messages, key)
args = _extract_args(data)
if args is not None:
# Pin the name we were given so a model name-drift never costs a repair.
args["name"] = name
errors = validate_tool_schema_core(args)
if not errors:
return args
# Rebuild from base + a single repair turn (avoids provider-specific
# tool_call/tool_result threading; each repair is a fresh forced call).
messages = [*base, _repair_message(args or {}, errors)]
raise LLMAssistError(
f"could not produce a valid schema after {_MAX_ATTEMPTS} attempts: "
+ "; ".join(errors)
)

View File

@@ -135,6 +135,30 @@ class ProgramDetail(ProgramSummary):
manifest: dict
class ToolStatusModel(BaseModel):
"""One host toolchain a stack needs, and whether it's present where used."""
command: str
purpose: str
phase: str # "run" | "build" | "both"
present: bool
install_hint: str
version: str | None = None
class StackStatusModel(BaseModel):
"""A stack's dependency health — its tools + who uses it. Powers the Stacks page."""
name: str
tools: list[ToolStatusModel]
programs: list[str]
deployments: list[str]
verbs: list[str]
has_enabled_deployment: bool
in_use: bool
ok: bool
class HealthStatus(BaseModel):
"""Health status of a single component."""

View File

@@ -3,14 +3,28 @@
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core import git
from castle_core.adopt import (
AdoptError,
build_adopted_program,
is_git_url,
looks_like_program,
)
from castle_core.config import write_program_file
from castle_core.stacks import available_actions, available_stacks, run_action
from castle_api import stream
from castle_api.config import get_config
from castle_api.models import StackStatusModel, ToolStatusModel
if TYPE_CHECKING:
from castle_core.stack_status import StackStatus
programs_router = APIRouter(tags=["programs"])
@@ -22,6 +36,137 @@ def list_stacks() -> list[str]:
return available_stacks()
def _stack_model(st: StackStatus) -> StackStatusModel:
return StackStatusModel(
name=st.name,
tools=[ToolStatusModel(**asdict(t)) for t in st.tools],
programs=st.programs,
deployments=st.deployments,
verbs=st.verbs,
has_enabled_deployment=st.has_enabled_deployment,
in_use=st.in_use,
ok=st.ok,
)
@programs_router.get("/stacks/status")
def stacks_status() -> list[StackStatusModel]:
"""Every stack's dependency health — tools present-where-needed (run-phase tools
against the service runtime PATH), who uses it, and the fix for anything missing.
The Stacks page renders this; `castle stack list` is its CLI twin."""
from castle_core.stack_status import all_stack_status
return [_stack_model(s) for s in all_stack_status(get_config())]
@programs_router.get("/stacks/{name}")
def stack_detail(name: str) -> StackStatusModel:
"""One stack's dependency detail (tool versions included)."""
from castle_core.stack_status import stack_status
st = stack_status(get_config(), name)
if st is None:
raise HTTPException(status_code=404, detail=f"No stack '{name}'")
return _stack_model(st)
# ---------------------------------------------------------------------------
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
# web equivalent of `castle program add <path|git-url>`. Programs live on the
# server's filesystem, so the picker browses the *server's* dirs (a browser's
# native file dialog only sees the client machine).
# ---------------------------------------------------------------------------
@programs_router.get("/fs/browse")
def browse_filesystem(path: str | None = None) -> dict:
"""List sub-directories of ``path`` (default: the repos dir) so the dashboard
can browse to a program on the server. Directories only; hidden dirs skipped.
Each entry is flagged when it looks adoptable (a project manifest or git repo).
"""
config = get_config()
base = Path(path).expanduser() if path else config.repos_dir
try:
base = base.resolve()
except OSError as e:
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
if not base.exists():
raise HTTPException(status_code=404, detail=f"Path does not exist: {base}")
if not base.is_dir():
raise HTTPException(status_code=400, detail=f"Not a directory: {base}")
try:
children = sorted(base.iterdir(), key=lambda p: p.name.lower())
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {base}")
entries: list[dict] = []
for child in children:
if child.name.startswith("."):
continue
# A single unreadable child (can't stat/traverse) shouldn't sink the whole
# listing — skip it rather than 403 the directory.
try:
if not child.is_dir():
continue
entries.append(
{
"name": child.name,
"path": str(child),
"is_program": looks_like_program(child),
"is_git": (child / ".git").exists(),
}
)
except OSError:
continue
parent = str(base.parent) if base.parent != base else None
return {
"path": str(base),
"parent": parent,
"repos_dir": str(config.repos_dir),
"entries": entries,
}
class AdoptRequest(BaseModel):
target: str # a local server path or a git URL
name: str | None = None
description: str = ""
@programs_router.post("/programs/adopt")
def adopt_program(request: AdoptRequest) -> dict:
"""Adopt an existing repo as a program (the web `castle program add`).
``target`` is a local server path or a git URL. Writes just the new program's
file; declaring a deployment (service/job/tool/static) stays a separate step.
"""
target = request.target.strip()
if not target:
raise HTTPException(status_code=422, detail="A path or git URL is required.")
config = get_config()
try:
adopted = build_adopted_program(
config, target, name=request.name, description=request.description
)
except AdoptError as e:
raise HTTPException(status_code=422, detail=str(e))
config.programs[adopted.name] = adopted.spec
write_program_file(config, adopted.name)
return {
"ok": True,
"program": adopted.name,
"source": adopted.source,
"stack": adopted.stack,
"repo": adopted.repo,
"commands": adopted.commands,
"is_git_url": is_git_url(target),
}
# ---------------------------------------------------------------------------
# Git sync — pull a program's source working copy up to date (pull only; no
# build/apply/restart — converge stays an explicit, separate step). Declared

View File

@@ -920,15 +920,18 @@ def get_gateway() -> GatewayInfo:
except FileNotFoundError:
pass
# Which local deployments are public → their public URL (<name>.<public_domain>).
# Scan ALL deployments, not just `config.services` — a public *static* (caddy)
# is not a service, so filtering to services dropped its public_url (calculator).
# Which local deployments are public → their public URL. A `public_host`
# override (apex / another zone) wins; otherwise <name>.<public_domain>.
public_domain = registry.node.public_domain
public_names = {
name
for _k, name, dep in (config.all_deployments() if config else [])
if getattr(dep, "public", False)
}
def _public_url(r) -> str | None:
if not getattr(r, "public", False):
return None
if r.public_host:
return f"https://{r.public_host}"
if public_domain:
return f"https://{r.name}.{public_domain}"
return None
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
routes = [
@@ -938,11 +941,7 @@ def get_gateway() -> GatewayInfo:
target=r.target,
name=r.name,
node=r.node or registry.node.hostname,
public_url=(
f"https://{r.name}.{public_domain}"
if public_domain and r.name in public_names
else None
),
public_url=_public_url(r),
)
for r in compute_routes(registry, config, remote or None)
]

View File

@@ -0,0 +1,29 @@
"""Tests for the stack-dependency endpoints (/stacks, /stacks/status, /stacks/{name})."""
from fastapi.testclient import TestClient
from castle_core.stacks import available_stacks
class TestStacks:
def test_names_endpoint_stays_a_bare_list(self, client: TestClient) -> None:
"""`GET /stacks` keeps its back-compat string[] shape (the create-form select
depends on it) even though the richer status lives at /stacks/status."""
resp = client.get("/stacks")
assert resp.status_code == 200
assert resp.json() == available_stacks()
def test_status_shape(self, client: TestClient) -> None:
resp = client.get("/stacks/status")
assert resp.status_code == 200
body = resp.json()
assert {s["name"] for s in body} == set(available_stacks())
st = next(s for s in body if s["name"] == "python-fastapi")
# python-fastapi declares uv; every tool carries its phase + fix.
assert {"in_use", "ok", "tools", "programs", "verbs"} <= st.keys()
uv = next(t for t in st["tools"] if t["command"] == "uv")
assert uv["phase"] == "both" and uv["install_hint"]
def test_detail_and_404(self, client: TestClient) -> None:
assert client.get("/stacks/python-cli").json()["name"] == "python-cli"
assert client.get("/stacks/nope").status_code == 404

View File

@@ -0,0 +1,199 @@
"""POST /config/tools/{name}/schema — derive an Anthropic tool schema from --help,
and the tool_schema field round-tripping through the tool save path."""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
import json
from castle_core.config import load_config
from castle_api.config import settings
def _completion(args: dict) -> dict:
"""A minimal chat-completions response carrying an emit_tool_schema call."""
return {
"choices": [
{"message": {"tool_calls": [
{"function": {"name": "emit_tool_schema", "arguments": json.dumps(args)}}
]}}
]
}
_BAD_CORE = {
"name": "python3", "description": "d",
"parameters": {"type": "object", "properties": {"m": {"type": "string", "enum": 3}}},
}
_GOOD_CORE = {
"name": "python3", "description": "d",
"parameters": {"type": "object", "properties": {"m": {"type": "string"}}},
}
# A tool whose program falls back to the tool name for its executable; `python3`
# is always on PATH, so derive succeeds deterministically.
_PY_TOOL = {"program": "python3", "manager": "path"}
class TestGenerateSchema:
def test_generate_returns_draft_not_saved(
self, client: TestClient, castle_root: Path
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
r = client.post("/config/tools/python3/schema")
assert r.status_code == 200, r.text
body = r.json()
schema = body["schema"]
# The stored draft is the neutral core (name/description/parameters),
# rendered to a provider envelope only on read.
assert schema["name"] == "python3"
assert "parameters" in schema
assert schema["parameters"]["properties"]
# It's a draft: nothing persisted until the client saves it.
cfg = load_config(castle_root)
assert cfg.tools["python3"].tool_schema is None
def test_unknown_tool_404(self, client: TestClient) -> None:
r = client.post("/config/tools/nope-not-a-tool/schema")
assert r.status_code == 404
def test_validate_endpoint_valid(self, client: TestClient) -> None:
r = client.post("/config/tools/schema/validate", json=_GOOD_CORE)
assert r.status_code == 200
assert r.json() == {"valid": True, "errors": []}
def test_validate_endpoint_invalid(self, client: TestClient) -> None:
r = client.post("/config/tools/schema/validate", json=_BAD_CORE)
assert r.status_code == 200
body = r.json()
assert body["valid"] is False and body["errors"]
def test_uninstalled_executable_422(
self, client: TestClient, castle_root: Path
) -> None:
client.put(
"/config/tools/ghosttool",
json={"config": {"program": "ghosttool", "manager": "path"}},
)
r = client.post("/config/tools/ghosttool/schema")
assert r.status_code == 422
assert "PATH" in r.json()["detail"]
def test_assist_disabled_returns_503(
self, client: TestClient, castle_root: Path
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
# llm_enabled is False by default.
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 503
assert "disabled" in r.json()["detail"]
def test_assist_success_returns_draft(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _fake_llm(help_text: str, name: str) -> dict:
assert "python3" in help_text # the recursive help was gathered
return {
"name": name,
"description": "the python interpreter",
"parameters": {
"type": "object",
"properties": {"script": {"type": "string"}},
},
}
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.generate_tool_schema_llm", _fake_llm)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 200, r.text
body = r.json()
assert body["assist"] == "llm"
assert body["schema"]["parameters"]["properties"] == {"script": {"type": "string"}}
def test_assist_upstream_error_returns_502(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _fail_llm(help_text: str, name: str) -> dict:
from castle_api.llm import LLMAssistError
raise LLMAssistError("litellm returned 500")
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.generate_tool_schema_llm", _fail_llm)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 502
assert "litellm" in r.json()["detail"]
def test_assist_repairs_invalid_then_valid(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
"""A malformed first response (bad enum) is fed back and repaired."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
calls = {"n": 0}
async def _fake_complete(messages: list, key: str) -> dict:
calls["n"] += 1
return _completion(_BAD_CORE if calls["n"] == 1 else _GOOD_CORE)
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.read_secret", lambda name: "k")
monkeypatch.setattr("castle_api.llm._complete", _fake_complete)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 200, r.text
assert calls["n"] == 2 # repaired on the second attempt
props = r.json()["schema"]["parameters"]["properties"]
assert props == {"m": {"type": "string"}}
def test_assist_unrepairable_returns_502(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
"""Persistently invalid output exhausts the repair budget → 502."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _always_bad(messages: list, key: str) -> dict:
return _completion(_BAD_CORE)
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.read_secret", lambda name: "k")
monkeypatch.setattr("castle_api.llm._complete", _always_bad)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 502
assert "attempts" in r.json()["detail"]
def test_schema_round_trips_through_save(
self, client: TestClient, castle_root: Path
) -> None:
"""Saving a tool with tool_schema persists it (PATCH merge), and clearing
it with null drops the field."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
schema = client.post("/config/tools/python3/schema").json()["schema"]
client.put(
"/config/tools/python3",
json={"config": {"program": "python3", "tool_schema": schema}},
)
cfg = load_config(castle_root)
assert cfg.tools["python3"].tool_schema is not None
assert cfg.tools["python3"].tool_schema["name"] == "python3"
# Clearing with null removes it (default None).
client.put(
"/config/tools/python3",
json={"config": {"program": "python3", "tool_schema": None}},
)
cfg = load_config(castle_root)
assert cfg.tools["python3"].tool_schema is None

View File

@@ -3,125 +3,43 @@
`castle program create` makes new code from a stack. `castle program add` adopts code that
already exists — a local path, or a git URL to clone. It detects sensible dev
verb commands so a non-castle project becomes usable without writing them by hand.
The adopt logic itself lives in ``castle_core.adopt`` so the CLI and the API
(`POST /programs/adopt`, the dashboard's "Add program") behave identically.
"""
from __future__ import annotations
import argparse
import tomllib
from pathlib import Path
from castle_core.adopt import AdoptError, build_adopted_program
from castle_cli.config import load_config, save_config
from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
def _is_git_url(s: str) -> bool:
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
"""Detect (stack, commands) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. `add` adopts source only; the
deployment (and thus kind) is declared separately, so no kind is inferred here.
"""
commands: dict[str, list[list[str]]] = {}
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
return stack, commands
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands
return None, commands
def run_add(args: argparse.Namespace) -> int:
"""Adopt an existing repo as a program."""
config = load_config()
target = args.target
repo_url: str | None = None
source: str | None = None
if _is_git_url(target):
repo_url = target
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
source = str(config.repos_dir / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
print(f"Error: path does not exist: {src_path}")
return 1
source = str(src_path)
name = args.name or src_path.name
if name in config.programs or config.deployments_named(name):
print(f"Error: '{name}' already exists in castle.yaml")
return 1
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so `castle add` adopts the
# source only; declare a deployment separately (castle service/job create).
stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {}
if src_path.exists():
stack, detected_commands = _detect(src_path)
prog = ProgramSpec(
id=name,
description=args.description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
try:
adopted = build_adopted_program(
config, args.target, name=args.name, description=args.description
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected_commands:
build_cmds = detected_commands.pop("build", None)
if build_cmds:
prog.build = BuildSpec(commands=build_cmds)
if detected_commands:
prog.commands = CommandsSpec.model_validate(detected_commands)
except AdoptError as e:
print(f"Error: {e}")
return 1
config.programs[name] = prog
config.programs[adopted.name] = adopted.spec
save_config(config)
print(f"Adopted '{name}' as a program.")
print(f" source: {source}")
if repo_url:
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)")
if stack:
print(f" stack: {stack} (verbs inherited from stack defaults)")
elif detected_commands:
print(f" commands detected: {', '.join(sorted(detected_commands))}")
print(f"Adopted '{adopted.name}' as a program.")
print(f" source: {adopted.source}")
if adopted.repo:
print(f" repo: {adopted.repo} (run 'castle clone {adopted.name}' to fetch it)")
if adopted.stack:
print(f" stack: {adopted.stack} (verbs inherited from stack defaults)")
elif adopted.commands:
print(f" commands detected: {', '.join(adopted.commands)}")
else:
print(" no stack/commands detected — declare verbs in castle.yaml as needed")
return 0

View File

@@ -48,6 +48,8 @@ def run_apply(args: argparse.Namespace) -> int:
_line(_C["activate"], "would activate ", result.activated)
_line(_C["restart"], "would restart ", result.restarted)
_line(_C["deactivate"], "would deactivate", result.deactivated)
if result.gateway_changed:
print(f" {_C['restart']}would reload {_C['reset']} gateway routes")
return 0
print("\n\033[1mApplied\033[0m")
@@ -57,4 +59,6 @@ def run_apply(args: argparse.Namespace) -> int:
_line(_C["activate"], "activated ", result.activated)
_line(_C["restart"], "restarted ", result.restarted)
_line(_C["deactivate"], "deactivated", result.deactivated)
if result.gateway_changed:
print(f" {_C['restart']}reloaded {_C['reset']} gateway routes")
return 0

View File

@@ -29,14 +29,16 @@ STACK_DEFAULTS: dict[str, str] = {
"python-cli": "tool",
"react-vite": "static",
"supabase": "static",
"hugo": "static",
}
# Static build output per stack, for `static` (caddy) deployments. The gateway
# serves this dir in place at <name>.<gateway.domain> (no service, no process).
# A supabase app ships a raw `public/`; react-vite builds to `dist/`.
# A supabase app ships a raw `public/`; react-vite builds to `dist/`; hugo to `public/`.
STACK_BUILD_OUTPUTS: dict[str, str] = {
"supabase": "public",
"react-vite": "dist",
"hugo": "public",
}
# Substrate a stack's apps depend on — seeded as a deployment `requires` at creation
@@ -180,6 +182,10 @@ def run_create(args: argparse.Namespace) -> int:
print(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" castle program build {name} # apply migrations to the substrate")
print(f" castle apply # serve at {name}.<gateway.domain>")
elif stack == "hugo":
print(" # edit content/, layouts/ (or add a theme under themes/)")
print(f" castle program build {name} # hugo --gc --minify -> public/")
print(f" castle apply {name} # serve at {name}.<gateway.domain>")
elif stack:
print(" uv sync")
if kind == "service":

View File

@@ -163,17 +163,20 @@ def run_delete(args: argparse.Namespace) -> int:
def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]:
"""The Cloudflare CNAMEs (<subdomain>.<public_domain>) of any public deployments
being removed — surfaced so the operator can clean up DNS."""
"""The Cloudflare CNAMEs (a ``public_host`` override, else
<subdomain>.<public_domain>) of any public deployments being removed —
surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None)
public_domain = getattr(gw, "public_domain", None) if gw else None
if not public_domain:
return []
hosts: list[str] = []
for kind, d in deployments:
spec = config.deployment(kind, d)
if spec is None or not getattr(spec, "public", False):
continue
override = getattr(spec, "public_host", None)
if override:
hosts.append(override)
elif public_domain:
sub = getattr(spec, "subdomain", None) or d
hosts.append(f"{sub}.{public_domain}")
return hosts

View File

@@ -345,15 +345,20 @@ def _check_tls_exposure(config) -> list[Check]:
checks.append(_check_privileged_ports())
# Public exposure (only relevant if a deployment opts in).
public = [n for _k, n, d in config.all_deployments() if getattr(d, "public", False)]
public_specs = [(n, d) for _k, n, d in config.all_deployments() if getattr(d, "public", False)]
public = [n for n, _d in public_specs]
if public:
from castle_core.lifecycle import is_active
if gw.public_domain and gw.tunnel_id:
# A public deployment gets its public name from its own `public_host`
# override or the node-wide `public_domain`; the default domain is only
# required if some public deployment relies on it.
need_default = any(not getattr(d, "public_host", None) for _n, d in public_specs)
if gw.tunnel_id and (gw.public_domain or not need_default):
checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)"))
else:
missing = []
if not gw.public_domain:
if need_default and not gw.public_domain:
missing.append("public_domain")
if not gw.tunnel_id:
missing.append("tunnel_id")
@@ -403,20 +408,26 @@ def _check_public_dns(config) -> Check:
"public DNS not automated",
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
hint=(
f"add a Cloudflare token with DNS:Edit on {gw.public_domain} "
f"add a Cloudflare token with DNS:Edit on "
f"{gw.public_domain or 'the public zone(s)'} "
f"('Edit zone DNS' template) → ~/.castle/secrets/{PUBLIC_DNS_TOKEN} "
"(else route each host by hand)"
),
)
try:
zres = (_api(token, "GET", f"/zones?name={gw.public_domain}").get("result")) or []
# With a node-wide public_domain, probe that specific zone; otherwise
# (custom public_host hosts only) confirm the token can list *some* zone —
# reconcile resolves each host's zone by longest-suffix match at apply.
query = f"/zones?name={gw.public_domain}" if gw.public_domain else "/zones?per_page=1"
zres = (_api(token, "GET", query).get("result")) or []
if not zres:
where = gw.public_domain or "any zone"
return Check(
FAIL,
"public DNS token can't see the zone",
detail=f"{gw.public_domain} not visible",
detail=f"{where} not visible",
hint=(
f"token needs DNS:Edit scoped to {gw.public_domain}, in that "
f"token needs DNS:Edit scoped to {where}, in that "
"zone's account ('Edit zone DNS' template)"
),
)
@@ -436,7 +447,7 @@ def _check_public_dns(config) -> Check:
return Check(
OK,
"public DNS token valid",
detail=f"can reach {gw.public_domain} + its records",
detail=f"can reach {gw.public_domain or 'its zones'} + records",
)
@@ -459,6 +470,39 @@ def _check_privileged_ports() -> Check:
# --- Driver -----------------------------------------------------------------
def _check_stacks(config: object) -> list[Check]:
"""Stack toolchains: is each *in-use* stack's host tooling present where its
programs need it (run-phase tools against the service's runtime PATH)? A missing
tool for an enabled deployment is a FAIL (its service can't build/run); missing
for a not-yet-enabled program is a WARN. Unused stacks are skipped — no nagging
about pnpm when there are no frontends."""
from castle_core.stack_status import all_stack_status
checks: list[Check] = []
for st in all_stack_status(config, with_version=False):
if not st.in_use or not st.tools:
continue
n = len(st.programs)
label = f"{st.name} ({n} program{'s' if n != 1 else ''})"
missing = [t for t in st.tools if not t.present]
if not missing:
present = ", ".join(t.command for t in st.tools)
checks.append(Check(OK, label, detail=f"{present} present"))
continue
status = FAIL if st.has_enabled_deployment else WARN
checks.append(
Check(
status,
label,
detail="missing: " + ", ".join(t.command for t in missing),
hint=missing[0].install_hint,
)
)
if not checks:
checks.append(Check(OK, "no stack toolchains in use"))
return checks
def run_doctor(args: argparse.Namespace) -> int:
from castle_core.config import load_config
@@ -482,6 +526,7 @@ def run_doctor(args: argparse.Namespace) -> int:
sections: list[tuple[str, list[Check]]] = [
("Environment", _check_environment()),
("Configuration", _check_configuration(config)),
("Stacks & dependencies", _check_stacks(config)),
("Runtime", _check_runtime(config)),
("TLS & exposure", _check_tls_exposure(config)),
]

View File

@@ -0,0 +1,110 @@
"""castle stack — the stacks lens (toolchains a program's stack needs).
A *stack* (python-fastapi, react-vite, hugo, …) is creation-time guidance that
also carries the **host toolchains** its programs need to build and run (`uv`,
`pnpm`, `hugo`, …). This lens makes those dependencies visible and tells you
whether they're present *where the running service needs them* — the drift a bare
`which` in your shell misses — with a copyable fix when one is absent.
"""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from typing import TYPE_CHECKING
from castle_cli.config import load_config
if TYPE_CHECKING:
from castle_core.stack_status import StackStatus
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
GREEN = "\033[92m"
RED = "\033[91m"
GREY = "\033[90m"
CYAN = "\033[96m"
def _record(st: StackStatus) -> dict:
d = asdict(st)
d["in_use"] = st.in_use
d["ok"] = st.ok
return d
def run_stack_list(args: argparse.Namespace) -> int:
"""List stacks with their toolchain health, program count, and dev verbs."""
from castle_core.stack_status import all_stack_status
config = load_config()
# Skip per-tool version probes for the list (a subprocess per tool) — keep it snappy.
stacks = all_stack_status(config, with_version=False)
if getattr(args, "json", False):
print(json.dumps([_record(s) for s in stacks], indent=2))
return 0
print(f"\n{BOLD}Stacks{RESET}")
print("" * 64)
width = max((len(s.name) for s in stacks), default=0)
for s in stacks:
if not s.tools:
dot = f"{GREY}{RESET}"
else:
dot = f"{GREEN}{RESET}" if s.ok else f"{RED}{RESET}"
missing = [t.command for t in s.tools if not t.present]
tools = ", ".join(
(f"{RED}{t.command}{RESET}" if not t.present else t.command) for t in s.tools
)
used = (
f"{len(s.programs)} program{'s' if len(s.programs) != 1 else ''}"
if s.in_use
else f"{GREY}unused{RESET}"
)
tail = f" {DIM}{used}{RESET}"
tools_str = f" {DIM}tools:{RESET} {tools}" if tools else ""
print(f" {dot} {BOLD}{s.name:<{width}}{RESET}{tools_str}{tail}")
if missing:
print(f" {RED}missing:{RESET} {', '.join(missing)}")
print()
return 0
def run_stack_info(args: argparse.Namespace) -> int:
"""Show one stack: each tool's presence + version + fix, and who uses it."""
from castle_core.stack_status import stack_status
config = load_config()
name = args.name
st = stack_status(config, name)
if st is None:
from castle_core.stacks import available_stacks
print(f"Error: no stack '{name}'. Known: {', '.join(available_stacks())}")
return 1
if getattr(args, "json", False):
print(json.dumps(_record(st), indent=2))
return 0
print(f"\n{BOLD}{st.name}{RESET}")
print("" * 48)
if st.verbs:
print(f" {BOLD}verbs{RESET}: {', '.join(st.verbs)}")
print(f" {BOLD}used by{RESET}: ", end="")
print(", ".join(st.programs) if st.programs else f"{GREY}nothing{RESET}")
print(f"\n {BOLD}toolchain{RESET}")
if not st.tools:
print(f" {GREY}no host tools required{RESET}")
for t in st.tools:
mark = f"{GREEN}{RESET}" if t.present else f"{RED}{RESET}"
ver = f" {DIM}{t.version}{RESET}" if t.version else ""
print(f" {mark} {BOLD}{t.command}{RESET} {DIM}{t.purpose} ({t.phase}){RESET}{ver}")
if not t.present:
print(f" {CYAN}{t.install_hint}{RESET}")
print()
return 0

View File

@@ -13,9 +13,14 @@ import argparse
import json
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING
from castle_cli.config import load_config
if TYPE_CHECKING:
from castle_core.config import CastleConfig
from castle_core.manifest import ProgramSpec
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
@@ -24,16 +29,16 @@ GREY = "\033[90m"
CYAN = "\033[96m"
def _is_tool(config: object, name: str) -> bool:
def _is_tool(config: CastleConfig, name: str) -> bool:
return any(kind == "tool" for _, kind in config.deployments_of(name))
def _tool_programs(config: object) -> dict:
def _tool_programs(config: CastleConfig) -> dict:
"""Programs with a tool (path) deployment, name-sorted."""
return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)}
def _executables(comp: object) -> list[str]:
def _executables(comp: ProgramSpec) -> list[str]:
"""The console scripts a tool exposes, from its pyproject `[project.scripts]`.
This is the command(s) to actually run — the source of truth even when the
@@ -54,7 +59,16 @@ def _executables(comp: object) -> list[str]:
return [comp.id]
def _tool_record(config: object, name: str, comp: object, installed: bool) -> dict:
def _tool_record(
config: CastleConfig, name: str, comp: ProgramSpec, installed: bool, fmt: str = "openai"
) -> dict:
# The tool-call schema (for handing this CLI to an agent) is stored neutral on
# the path deployment; render it into the requested envelope for the --json
# context payload. Feed default is openai (litellm-native).
from castle_core.tool_schema import render_tool_schema
dep = config.deployment("tool", name)
core = getattr(dep, "tool_schema", None)
return {
"name": name,
"executables": _executables(comp),
@@ -63,6 +77,7 @@ def _tool_record(config: object, name: str, comp: object, installed: bool) -> di
"stack": comp.stack,
"source": comp.source,
"system_dependencies": comp.system_dependencies,
"tool_schema": render_tool_schema(core, fmt) if core else None,
}
@@ -72,8 +87,10 @@ def run_tool_list(args: argparse.Namespace) -> int:
config = load_config()
tools = _tool_programs(config)
fmt = getattr(args, "format", "openai")
records = [
_tool_record(config, name, comp, tool_installed(name)) for name, comp in tools.items()
_tool_record(config, name, comp, tool_installed(name), fmt)
for name, comp in tools.items()
]
if getattr(args, "json", False):
@@ -112,7 +129,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
comp = config.programs[name]
installed = tool_installed(name)
record = _tool_record(config, name, comp, installed)
record = _tool_record(config, name, comp, installed, getattr(args, "format", "openai"))
if getattr(args, "json", False):
print(json.dumps(record, indent=2))

View File

@@ -81,12 +81,31 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
grp.set_defaults(resource="tool")
sub = grp.add_subparsers(dest="tool_command")
fmt_help = "Render each tool_schema in this envelope (default: openai)"
fmt_choices = ("openai", "anthropic", "neutral")
p = sub.add_parser("list", help="List tools with their executable + description")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
p = sub.add_parser("info", help="Show a tool's executable, description, install state")
_add_name(p, "Tool name")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
def _build_stack_group(subparsers: argparse._SubParsersAction) -> None:
"""The `stack` lens — the toolchains each stack needs + whether they're present."""
grp = subparsers.add_parser("stack", help="Stacks + the toolchains they require")
grp.set_defaults(resource="stack")
sub = grp.add_subparsers(dest="stack_command")
p = sub.add_parser("list", help="List stacks with their toolchain health")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p = sub.add_parser("info", help="Show a stack's tools, versions, fixes, and users")
_add_name(p, "Stack name")
p.add_argument("--json", action="store_true", help="Machine-readable output")
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
@@ -161,6 +180,7 @@ def build_parser() -> argparse.ArgumentParser:
_build_deployment_group(subparsers, "service")
_build_deployment_group(subparsers, "job")
_build_tool_group(subparsers)
_build_stack_group(subparsers)
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via
# `castle apply` / `castle restart castle-gateway`; this lens just shows routes.
@@ -282,6 +302,22 @@ def _dispatch_tool(args: argparse.Namespace) -> int:
return 1
def _dispatch_stack(args: argparse.Namespace) -> int:
sub = args.stack_command
if not sub:
print("Usage: castle stack {list|info}")
return 1
if sub == "list":
from castle_cli.commands.stack import run_stack_list
return run_stack_list(args)
if sub == "info":
from castle_cli.commands.stack import run_stack_info
return run_stack_info(args)
return 1
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
sub = getattr(args, f"{kind}_command")
if not sub:
@@ -330,6 +366,8 @@ def main() -> int:
return _dispatch_deployment(args, cmd)
if cmd == "tool":
return _dispatch_tool(args)
if cmd == "stack":
return _dispatch_stack(args)
if cmd == "gateway":
from castle_cli.commands.gateway import run_gateway

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
@@ -20,6 +22,8 @@ def scaffold_project(
_scaffold_tool(project_dir, name, package_name, description)
elif stack == "supabase":
_scaffold_supabase(project_dir, name, description)
elif stack == "hugo":
_scaffold_hugo(project_dir, name, description)
else:
raise ValueError(f"No scaffold template for stack: {stack}")
@@ -771,6 +775,200 @@ Auth/WebCrypto apps should get their own HTTPS host route (secure context).
)
def _hugo_new_site(project_dir: Path) -> bool:
"""Create the canonical site skeleton with Hugo's own scaffolder.
Delegates the standard structure (archetypes/, the content/layouts/static/…
dir tree, hugo.toml) to `hugo new site` rather than reinventing it. Returns
False if the hugo binary isn't present at create time — the caller then falls
back to a minimal hand-written skeleton (the build needs hugo regardless)."""
if shutil.which("hugo") is None:
return False
# `hugo new site` requires an empty/absent target; create runs before git init,
# so project_dir doesn't exist yet.
result = subprocess.run(
["hugo", "new", "site", str(project_dir), "--format", "toml"],
capture_output=True,
text=True,
)
return result.returncode == 0
def _scaffold_hugo(project_dir: Path, name: str, description: str) -> None:
"""Scaffold a Hugo site that builds and serves theme-less out of the box.
The canonical skeleton comes from Hugo's own `hugo new site`; on top of it we
overlay the pieces a bare skeleton lacks — the layouts needed to render a home
page without a theme, sample content, and a castle-flavored hugo.toml/README.
`castle program build` runs `hugo --gc --minify` → `public/`, which a caddy
deployment serves in place at <name>.<gateway.domain>. baseURL is `/` so assets
resolve at the root of the site's own subdomain. A theme (with its own asset
pipeline) can be dropped under themes/ later; such themes declare a two-step
`build.commands` in the program spec, which overrides the stack default."""
def sub(text: str) -> str:
return text.replace("__NAME__", name).replace("__DESC__", description)
# Prefer Hugo's native scaffolder; fall back to the bits it would have made.
if not _hugo_new_site(project_dir):
_write(
project_dir / "archetypes" / "default.md",
"""+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++
""",
)
# --- hugo.toml — overwrite Hugo's example.org default; baseURL '/' serves at
# the subdomain root ---
_write(
project_dir / "hugo.toml",
sub(
"""baseURL = "/"
languageCode = "en-us"
title = "__NAME__"
[params]
description = "__DESC__"
"""
),
)
# --- layouts/_default/baseof.html — the shared page skeleton (blocks) ---
_write(
project_dir / "layouts" / "_default" / "baseof.html",
sub(
"""<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode | default "en" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
<meta name="description" content="{{ .Site.Params.description }}">
</head>
<body>
<header><a href="{{ "/" | relURL }}">{{ .Site.Title }}</a></header>
<main>{{ block "main" . }}{{ end }}</main>
<footer>&copy; {{ now.Year }} {{ .Site.Title }}</footer>
</body>
</html>
"""
),
)
# --- layouts/index.html — the home page ---
_write(
project_dir / "layouts" / "index.html",
"""{{ define "main" }}
{{ .Content }}
<ul>
{{ range (where .Site.RegularPages "Type" "posts") }}
<li>
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
</li>
{{ end }}
</ul>
{{ end }}
""",
)
# --- layouts/_default/{single,list}.html — content pages ---
_write(
project_dir / "layouts" / "_default" / "single.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<article>
<h1>{{ .Title }}</h1>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
{{ .Content }}
</article>
{{ end }}
""",
)
_write(
project_dir / "layouts" / "_default" / "list.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<h1>{{ .Title }}</h1>
{{ .Content }}
<ul>
{{ range .Pages }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
""",
)
# --- content — home + an example post ---
_write(
project_dir / "content" / "_index.md",
sub(
"""---
title: "__NAME__"
---
Welcome to **__NAME__** — a Hugo site on castle. Edit `content/_index.md` and
add posts under `content/posts/`.
"""
),
)
_write(
project_dir / "content" / "posts" / "hello.md",
"""---
title: "Hello, world"
date: 2024-01-01
---
Your first post. Edit me in `content/posts/`, or run `hugo new posts/next.md`.
""",
)
# --- .gitignore — build output is regenerated, never committed ---
_write(
project_dir / ".gitignore",
"/public/\n/resources/\n.hugo_build.lock\n",
)
# --- README ---
_write(
project_dir / "README.md",
sub(
"""# __NAME__
__DESC__
A [Hugo](https://gohugo.io) static site managed by castle.
## Develop
hugo server -D # live preview at http://localhost:1313
## Build & serve
castle program build __NAME__ # hugo --gc --minify -> public/
castle apply __NAME__ # serve at __NAME__.<gateway.domain>
## Themes
Drop a theme under `themes/` (often a git submodule) and set `theme` in
`hugo.toml`. A theme with an asset pipeline (Tailwind, etc.) needs a pre-build
step; declare it in `programs/__NAME__.yaml` so it overrides the stack default:
build:
commands:
- [pnpm, build]
- [hugo, --gc, --minify]
outputs: [public]
"""
),
)
def _write(path: Path, content: str) -> None:
"""Write content to a file, creating parent directories."""
path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -7,7 +7,14 @@ from pathlib import Path
from unittest.mock import patch
import pytest
from castle_cli.commands.doctor import FAIL, OK, WARN, _check_configuration, run_doctor
from castle_cli.commands.doctor import (
FAIL,
OK,
WARN,
_check_configuration,
_check_stacks,
run_doctor,
)
class TestDoctor:
@@ -85,3 +92,57 @@ class TestDataDirChecks:
warn = next(c for c in _check_configuration(cfg) if "overrides castle.yaml" in c.label)
assert warn.status == WARN
assert "CASTLE_DATA_DIR" in warn.detail
class TestStackChecks:
"""The stacks section: a stack's toolchain must be present where its programs
run. Missing tooling for an enabled deployment is a FAIL with a copyable fix."""
def _fastapi_cfg(self):
import castle_core.config as C
from castle_core.manifest import ProgramSpec, SystemdDeployment
prog = ProgramSpec(id="svc", stack="python-fastapi")
dep = SystemdDeployment.model_validate(
{
"manager": "systemd",
"program": "svc",
"run": {"launcher": "command", "argv": ["svc"]},
}
)
return C.CastleConfig(
root=None,
gateway=C.GatewayConfig(port=9000),
repo=None,
programs={"svc": prog},
deployments={"svc": dep},
)
def test_present_tool_is_ok(self, monkeypatch: pytest.MonkeyPatch) -> None:
import castle_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
fa = next(
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
)
assert fa.status == OK
def test_missing_tool_for_enabled_deployment_fails_with_hint(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
import castle_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: False)
fa = next(
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
)
assert fa.status == FAIL
assert "uv" in fa.detail and fa.hint # names the tool + offers a fix
def test_unused_stacks_are_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""No react-vite program → no pnpm nag."""
import castle_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
labels = [c.label for c in _check_stacks(self._fastapi_cfg())]
assert not any(label.startswith("react-vite") for label in labels)

View File

@@ -6,6 +6,7 @@ requires-python = ">=3.11"
dependencies = [
"pyyaml>=6.0.0",
"pydantic>=2.0.0",
"jsonschema>=4.0.0",
]
[build-system]

View File

@@ -0,0 +1,157 @@
"""Adopt an existing repo as a program — shared by the CLI (`castle program add`)
and the API (`POST /programs/adopt`).
`create` scaffolds new code from a stack; `add` adopts code that already exists —
a local path, or a git URL to clone later. Keeping the target parsing, stack /
command sniffing, and ProgramSpec build here means both front-ends behave
identically (the logic used to live only in the CLI).
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from castle_core.config import CastleConfig
from castle_core.manifest import BuildSpec, CommandsSpec, ProgramSpec
class AdoptError(ValueError):
"""A program can't be adopted (bad path, or the name already exists)."""
def is_git_url(s: str) -> bool:
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
def looks_like_program(src: Path) -> bool:
"""Whether a directory holds something castle can adopt (a project manifest or
a git repo). Used to flag candidates in the filesystem browser."""
return (
(src / ".git").exists()
or (src / "pyproject.toml").exists()
or (src / "Cargo.toml").exists()
or (src / "package.json").exists()
or (src / "Makefile").exists()
or (src / "makefile").exists()
)
def detect_stack_commands(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
"""Detect (stack, commands) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. Adoption takes source only; the
deployment (and thus kind) is declared separately, so no kind is inferred here.
"""
commands: dict[str, list[list[str]]] = {}
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
return stack, commands
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands
return None, commands
@dataclass
class Adopted:
"""The result of building a program spec from an adopt target — the caller
persists it (``config.programs[name] = spec``; then save/write)."""
name: str
spec: ProgramSpec
source: str
stack: str | None = None
repo: str | None = None
commands: list[str] = field(default_factory=list)
def build_adopted_program(
config: CastleConfig,
target: str,
name: str | None = None,
description: str = "",
) -> Adopted:
"""Build (but do not save) a ProgramSpec adopting ``target``.
``target`` is a local path or a git URL. Raises :class:`AdoptError` if a local
path doesn't exist or the resolved name already exists in the config. Does not
mutate ``config`` — the caller assigns ``config.programs[name]`` and persists.
"""
repo_url: str | None = None
if is_git_url(target):
repo_url = target
name = name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
source = str(config.repos_dir / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
raise AdoptError(f"path does not exist: {src_path}")
source = str(src_path)
name = name or src_path.name
if name in config.programs or config.deployments_named(name):
raise AdoptError(f"'{name}' already exists in castle.yaml")
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so adoption takes source only;
# declare a deployment separately (castle service/job create).
stack: str | None = None
detected: dict[str, list[list[str]]] = {}
if src_path.exists():
stack, detected = detect_stack_commands(src_path)
spec = ProgramSpec(
id=name,
description=description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected:
build_cmds = detected.pop("build", None)
if build_cmds:
spec.build = BuildSpec(commands=build_cmds)
if detected:
spec.commands = CommandsSpec.model_validate(detected)
return Adopted(
name=name,
spec=spec,
source=source,
stack=stack,
repo=repo_url,
commands=sorted(detected),
)

View File

@@ -89,6 +89,7 @@ USER_TOOL_PATH_DIRS = [
Path.home() / ".local" / "bin",
Path.home() / ".local" / "share" / "pnpm" / "bin",
Path.home() / ".local" / "share" / "pnpm",
Path.home() / ".deno" / "bin", # deno's installer target (supabase edge fns)
Path("/usr/local/go/bin"),
]

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import os
import shutil
import subprocess
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
@@ -86,13 +87,26 @@ class ApplyResult:
pruned: list[str] = field(default_factory=list)
messages: list[str] = field(default_factory=list)
registry: NodeRegistry | None = None
# Gateway routing (the Caddyfile / cloudflared ingress) differs from what's live:
# a caddy static/proxy route was added, removed, or had its root/reach changed.
# Tracked separately because such a delta touches no systemd unit, so the
# activate/restart/deactivate reconcile never classifies it as a change. apply()
# rewrites the artifacts and reloads the gateway regardless; this flag just lets
# the summary report it instead of a false "already converged".
gateway_changed: bool = False
# True for a `--plan` run: the diff was computed but nothing was written or
# activated. Lets callers render "would activate…" vs "activated…".
planned: bool = False
@property
def changed(self) -> bool:
return bool(self.activated or self.restarted or self.deactivated or self.pruned)
return bool(
self.activated
or self.restarted
or self.deactivated
or self.pruned
or self.gateway_changed
)
def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult:
@@ -205,6 +219,48 @@ def _unit_bytes(name: str, kind: str) -> str | None:
return path.read_text() if path.exists() else None
def _desired_registry(config: CastleConfig, target_name: str | None) -> NodeRegistry:
"""The registry ``deploy()`` would write for this (optionally scoped) run.
Mirrors deploy()'s registry build: a scoped run merges the updated target over
the existing on-disk registry; a full run starts fresh. Used to predict
gateway-route deltas without writing anything."""
node = _node_config(config)
if target_name and REGISTRY_PATH.exists():
try:
registry = NodeRegistry(node=node, deployed=dict(load_registry().deployed))
except (FileNotFoundError, ValueError):
registry = NodeRegistry(node=node)
else:
registry = NodeRegistry(node=node)
for _kind, name, dep in config.all_deployments():
if target_name and name != target_name:
continue
deployed = _build_deployed(config, name, dep, [])
deployed.name = name
registry.put(deployed)
return registry
def _gateway_would_change(config: CastleConfig, target_name: str | None) -> bool:
"""Whether applying would rewrite the gateway's routing artifacts — the
Caddyfile or the cloudflared ingress — vs. what's on disk.
A pure caddy route change (new static, changed ``root``/``reach``, toggled
public) touches no systemd unit, so the activate/restart/deactivate reconcile
can't see it; without this the summary reports "already converged" despite a
live routing change. Compared before ``deploy()`` rewrites the artifacts, so it
reflects the pre-apply delta for both the plan and the real run."""
registry = _desired_registry(config, target_name)
caddyfile = SPECS_DIR / "Caddyfile"
current_caddy = caddyfile.read_text() if caddyfile.exists() else None
if generate_caddyfile_from_registry(registry) != current_caddy:
return True
tunnel_path = SPECS_DIR / "cloudflared.yml"
current_tunnel = tunnel_path.read_text() if tunnel_path.exists() else None
return generate_tunnel_config(registry) != current_tunnel
def apply(
target_name: str | None = None,
root: Path | None = None,
@@ -264,11 +320,17 @@ def apply(
deployed={NodeRegistry.key(k, n): d for (k, n), d in desired.items()},
)
)
# Gateway routing lives in the Caddyfile / cloudflared ingress, not a systemd
# unit, so _classify above can't see a route-only change. Detect it here against
# the on-disk artifacts (before deploy() rewrites them) so both the plan and the
# real run report it instead of a false "already converged".
result.gateway_changed = _gateway_would_change(config, target_name)
if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart.
result.planned = True
_stack_preflight(config, items, result.messages)
for k, n, _ in items:
after = _render_unit_preview(config, n, desired[(k, n)], k)
_record(result, n, _classify((k, n), after))
@@ -279,6 +341,7 @@ def apply(
deploy_result = deploy(target_name, root)
result.messages = list(deploy_result.messages)
result.registry = deploy_result.registry
_stack_preflight(config, items, result.messages)
# Materialize TLS cert files before (re)starting so a TLS service finds them on
# start. On a fresh node the gateway reload above only kicks off ACME issuance,
@@ -312,6 +375,32 @@ def apply(
return result
def _stack_preflight(
config: CastleConfig,
items: Sequence[tuple[str, str, object]],
messages: list[str],
) -> None:
"""Warn (never fail) when an enabled deployment's stack toolchain is missing
*where it runs* — the moment drift actually bites: a service whose `uv`/`pnpm`
isn't on its runtime PATH won't build or start. Mirrors `_acme_preflight`: an
advisory message, no writes, no gate. The exact fix comes from the tool's hint."""
from castle_core.relations import _tool_available
from castle_core.stacks import tools_for
for _k, n, spec in items:
if not getattr(spec, "enabled", True):
continue
prog = config.programs.get(getattr(spec, "program", None) or n)
if not prog or not prog.stack:
continue
for tool in tools_for(prog.stack):
if not _tool_available(spec, tool):
messages.append(
f"Warning: {n} ({prog.stack}) needs '{tool.command}' but it's "
f"missing where the service runs — {tool.install_hint}"
)
def _record(result: ApplyResult, name: str, action: str) -> None:
{
"activate": result.activated,
@@ -391,7 +480,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
config_path.unlink()
messages.append("No public services — removed cloudflared config.")
# Still reconcile so any CNAMEs castle created earlier are cleaned up.
reconcile_public_dns(node.public_domain, node.tunnel_id, [], messages)
reconcile_public_dns(node.tunnel_id, [], messages)
return
config_path.write_text(content)
@@ -399,7 +488,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
# `cloudflared tunnel route dns` commands when no DNS token is configured.
if not reconcile_public_dns(node.public_domain, node.tunnel_id, hosts, messages):
if not reconcile_public_dns(node.tunnel_id, hosts, messages):
for h in hosts:
messages.append(
f" public: {h} "
@@ -652,6 +741,7 @@ def _build_deployed(
stack=stack,
subdomain=name,
public=bool(dep.public),
public_host=(dep.public_host if dep.public else None),
static_root=static_root,
managed=False,
enabled=dep.enabled,
@@ -785,6 +875,7 @@ def _build_deployed(
health_path=health_path,
subdomain=(name if expose else None),
public=bool(dep.public and expose),
public_host=(dep.public_host if (dep.public and expose) else None),
tcp_port=tcp_port,
schedule=getattr(dep, "schedule", None),
managed=managed,

View File

@@ -40,6 +40,10 @@ class GatewayRoute:
target: str # static: serve dir; proxy: "localhost:PORT"/base_url; remote: "host:PORT"
name: str | None = None # backing program/service
node: str | None = None
public: bool = False # also served under public_domain
# Optional exact public-facing FQDN override (apex allowed). When set, the
# public name is this instead of the derived <address>.<public_domain>.
public_host: str | None = None
@property
def is_host(self) -> bool:
@@ -66,15 +70,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
def _local_routes(
config: CastleConfig | None, registry: NodeRegistry
) -> list[tuple[str, str, str]]:
"""Each local deployment's route as ``(name, kind, target)``, name-sorted.
) -> list[tuple[str, str, str, bool, str | None]]:
"""Each local deployment's route as ``(name, kind, target, public, public_host)``.
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
falls back to the deployed registry snapshot when config isn't available.
"""
out: list[tuple[str, str, str]] = []
out: list[tuple[str, str, str, bool, str | None]] = []
if config is not None:
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
# jobs/tools/references never do.
@@ -86,20 +90,22 @@ def _local_routes(
if kind == "static" and isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program)
if src is not None:
out.append((name, "static", str(src / dep.root)))
pub_host = dep.public_host if dep.public else None
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host))
elif kind == "service" and isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url):
out.append((name, "proxy", base_url or f"localhost:{port}"))
pub_host = dep.public_host if dep.public else None
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host))
return out
# No config → route from the deployed registry snapshot.
for _kind, name, d in registry.all():
if not d.enabled:
continue
if d.static_root:
out.append((name, "static", d.static_root))
out.append((name, "static", d.static_root, d.public, d.public_host))
elif d.subdomain and (d.port or d.base_url):
out.append((name, "proxy", d.base_url or f"localhost:{d.port}"))
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host))
return out
@@ -140,8 +146,8 @@ def compute_routes(
# built dir; everything else that's exposed reverse-proxies its port/base_url.
# (Static frontends are `runner: static` services now — no separate program
# branch, so routing derives from one place.)
for name, kind, target in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node))
for name, kind, target, is_public, pub_host in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host))
if remote_registries:
routes.extend(_remote_routes(config, registry, remote_registries))
@@ -237,6 +243,34 @@ def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]:
]
def _public_site_block(host: str, kind: str, target: str) -> list[str]:
"""A standalone Caddy site for a custom ``public_host`` (apex or another zone).
Unlike the ``*.<public_domain>`` wildcard site, an apex/foreign host isn't
covered by a wildcard cert, so it gets its own site. Caddy issues that exact
host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
Cloudflare token can edit the host's zone."""
if kind == "static":
body = [
f" root * {target}",
" try_files {path} /index.html",
" file_server",
]
elif kind == "remote":
body = [
f" reverse_proxy {target} {{",
" lb_try_duration 1s",
" fail_duration 30s",
" transport http {",
" dial_timeout 2s",
" }",
" }",
]
else:
body = [f" reverse_proxy {target}"]
return [f"{host} {{", *body, "}", ""]
# Castle's own control plane: the dashboard frontend and the API it calls. These
# names are the subdomains they're published at in acme mode, and the pair served
# on the :<port> site in off mode (no domain → no subdomains).
@@ -307,6 +341,34 @@ def generate_caddyfile_from_registry(
lines += _host_matcher_block(r.name or r.address, host, r.target)
lines.append("}")
lines.append("")
# Public exposure: public deployments are also reachable by their public
# name so LAN clients can use it directly. Two shapes:
# - default → <address>.<public_domain>, all served by one wildcard site
# (*.<public_domain>, its own wildcard cert), when a node-wide
# public_domain distinct from the internal zone is configured.
# - override → an exact `public_host` (an apex or a name in another zone),
# which a wildcard can't cover, so each gets a standalone site with its
# own cert (DNS-01 via the global acme_dns).
public_domain = node.public_domain
public_routes = [r for r in routes if r.public]
default_pub = [r for r in public_routes if not r.public_host]
custom_pub = [r for r in public_routes if r.public_host]
if public_domain and public_domain != domain and default_pub:
lines.append(f"*.{public_domain} {{")
for r in default_pub:
host = f"{r.address}.{public_domain}"
label = f"{r.name or r.address}_pub"
if r.kind == "static":
lines += _host_static_block(label, host, r.target)
elif r.kind == "remote":
lines += _host_remote_block(label, host, r.target)
else:
lines += _host_matcher_block(label, host, r.target)
lines.append("}")
lines.append("")
for r in custom_pub:
if r.public_host: # always true (custom_pub filter); narrows the type
lines += _public_site_block(r.public_host, r.kind, r.target)
# Redirect the bare gateway port to the dashboard subdomain.
lines += [
f":{gw_port} {{",

View File

@@ -1,13 +1,15 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel:
on deploy it creates one per public service and deletes any that point at this
tunnel but no longer correspond to a public service. It **only ever touches
records whose content is `<tunnel_id>.cfargotunnel.com`** — never other records in
the zone — so a hand-managed A/CNAME in the same zone is safe.
Castle owns the CNAMEs — across every zone the token can see — that point at its
Cloudflare tunnel: on deploy it creates one per public host (each routed to the
accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
work) and deletes any that point at this tunnel but no longer correspond to a
public service. It **only ever touches records whose content is
`<tunnel_id>.cfargotunnel.com`** — never other records in a zone — so a
hand-managed A/CNAME in the same zone is safe.
Needs a Cloudflare API token with **DNS:Edit** on the public zone (Cloudflare's
"Edit zone DNS" template — that single permission both resolves the zone by name
Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
"Edit zone DNS" template — that single permission both lists the accessible zones
and edits records; no separate Zone:Read is needed), stored at
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
@@ -43,65 +45,100 @@ def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
return json.loads(resp.read())
def _zone_for(host: str, zones: list[dict]) -> dict | None:
"""The visible zone whose name is the longest suffix of ``host`` (or None).
Longest-suffix so an apex (``example.com`` in zone ``example.com``) and a
subdomain in any accessible zone both resolve, even when zones nest.
"""
matches = [
z
for z in zones
if host == z["name"] or host.endswith("." + z["name"])
]
return max(matches, key=lambda z: len(z["name"])) if matches else None
def reconcile_public_dns(
public_domain: str | None,
tunnel_id: str | None,
desired_hosts: list[str],
messages: list[str],
token: str | None = None,
) -> bool:
"""Make the public zone's tunnel CNAMEs exactly `desired_hosts`.
"""Make the tunnel CNAMEs across every accessible zone exactly `desired_hosts`.
Creates missing CNAMEs (proxied the tunnel) and deletes castle-managed ones
(content == `<tunnel_id>.cfargotunnel.com`) not in `desired_hosts`. Never
touches records pointing elsewhere.
Each desired host is routed to the accessible zone whose name is its longest
suffix (so apex hosts and hosts in different zones are handled), then per zone
castle creates missing CNAMEs (proxied → the tunnel; Cloudflare flattens apex
CNAMEs) and deletes castle-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
no longer desired. Never touches records pointing elsewhere. Scanning every
visible zone also cleans up stale CNAMEs after a host moves zones or all public
services are removed.
Returns True if reconciliation was attempted (a token was configured) — the
caller then suppresses the manual route hints — or False if skipped (no token /
no tunnel / no public domain), so the caller can fall back to those hints.
no tunnel), so the caller can fall back to those hints.
"""
token = token or public_dns_token()
if not (token and public_domain and tunnel_id):
if not (token and tunnel_id):
return False
target = f"{tunnel_id}.cfargotunnel.com"
try:
zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or []
if not zres:
zones = (_api(token, "GET", "/zones?per_page=50").get("result")) or []
if not zones:
messages.append(
f"Warning: DNS token can't see zone '{public_domain}' — public "
"CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's "
f"'Edit zone DNS' template) scoped to {public_domain}."
"Warning: DNS token can't see any zone — public CNAMEs not "
"reconciled. The token needs DNS:Edit (Cloudflare's 'Edit zone "
"DNS' template) on the target zone(s)."
)
return False
zone_id = zres[0]["id"]
# Castle-managed set = existing CNAMEs whose content is our tunnel.
return True
# Route each desired host to its zone (longest-suffix match). Hosts with no
# accessible zone can't be created — surface them rather than silently drop.
desired_by_zone: dict[str, set[str]] = {z["id"]: set() for z in zones}
for host in desired_hosts:
z = _zone_for(host, zones)
if z is None:
messages.append(
f"Warning: no accessible Cloudflare zone for public host "
f"'{host}' — its CNAME was not created. The DNS token needs "
f"DNS:Edit on that host's zone."
)
continue
desired_by_zone[z["id"]].add(host)
created: list[str] = []
removed: list[str] = []
# Reconcile every visible zone (not just those with desired hosts) so a
# CNAME orphaned by a host moving zones / going internal is cleaned up.
for z in zones:
zone_id = z["id"]
recs = _api(
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
).get("result") or []
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
desired = set(desired_hosts)
created = sorted(desired - set(managed))
removed = sorted(set(managed) - desired)
for host in created:
desired = desired_by_zone[zone_id]
for host in sorted(desired - set(managed)):
_api(
token,
"POST",
f"/zones/{zone_id}/dns_records",
{"type": "CNAME", "name": host, "content": target, "proxied": True},
)
for host in removed:
created.append(host)
for host in sorted(set(managed) - desired):
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
removed.append(host)
if created or removed:
parts = []
if created:
parts.append(f"+{len(created)} ({', '.join(created)})")
parts.append(f"+{len(created)} ({', '.join(sorted(created))})")
if removed:
parts.append(f"-{len(removed)} ({', '.join(removed)})")
parts.append(f"-{len(removed)} ({', '.join(sorted(removed))})")
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
else:
messages.append(f"Public DNS up to date ({len(desired)} CNAME(s)).")
messages.append(f"Public DNS up to date ({len(desired_hosts)} CNAME(s)).")
return True
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]

View File

@@ -17,6 +17,19 @@ UNIT_PREFIX = "castle-"
SECRET_ENV_DIR = SECRETS_DIR / "env"
def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
"""The PATH a castle service runs with: resolved toolchain dirs (e.g. a pinned
node bin) + the user tool dirs that exist + system bins. This is the single
definition of a service's runtime PATH — the unit generator writes it into
``Environment=PATH`` and the dependency checker (``relations``) probes tools
against it, so the two can never disagree about where a service finds its tools.
"""
dirs = list(path_prepend)
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
return ":".join(dirs)
def unit_basename(name: str, kind: str = "service") -> str:
"""The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
service and a job can share a name (`castle-<name>.service` vs
@@ -127,10 +140,7 @@ def generate_unit_from_deployed(
# ${PATH} across Environment= lines, so a service that overrides PATH must
# spell out the full value, tool dirs included.
if "PATH" not in deployed.env:
dirs = list(deployed.path_prepend) # resolved toolchain (e.g. pinned node bin)
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
env_lines += f'Environment="PATH={":".join(dirs)}"\n'
env_lines += f'Environment="PATH={runtime_path(deployed.path_prepend)}"\n'
if env_file is not None:
env_lines += f"EnvironmentFile={env_file}\n"
@@ -176,7 +186,7 @@ SuccessExitStatus=143
# Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored,
# so a hiccup in the hook never fails the unit.
for cmd in (sd.exec_start_post if sd else []):
for cmd in sd.exec_start_post if sd else []:
argv = cmd.split()
resolved = shutil.which(argv[0])
if resolved:

View File

@@ -37,21 +37,42 @@ def tunnel_credentials_path(tunnel_id: str) -> Path:
return TUNNEL_CREDENTIALS_DIR / f"{tunnel_id}.json"
def public_fqdn(d: Deployment, node) -> str | None:
"""The public-facing hostname for a deployment, or None if it has none.
A deployment may override its public name with an exact FQDN (``public_host`` —
an apex like ``example.com`` or a name in another zone); otherwise it publishes
at ``<subdomain>.<public_domain>`` using the node-wide default public domain.
None when neither an override nor a default public domain is available.
"""
if d.public_host:
return d.public_host
if node.public_domain and d.subdomain:
return f"{d.subdomain}.{node.public_domain}"
return None
def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
"""The deployed services flagged public (and actually routed), name-sorted."""
return sorted(
(
(name, d)
for _kind, name, d in registry.all()
if d.public and d.subdomain
),
key=lambda nd: nd[0],
)
def public_hostnames(registry: NodeRegistry) -> list[str]:
"""The public hostnames that need a DNS route (``<sub>.<public_domain>``)."""
dom = registry.node.public_domain
if not dom:
return []
return [f"{d.subdomain}.{dom}" for _, d in public_deployments(registry)]
"""The public hostnames that need a DNS route.
Each is either a per-deployment ``public_host`` override or the default
``<sub>.<public_domain>``; deployments with neither are skipped.
"""
node = registry.node
hosts = [public_fqdn(d, node) for _, d in public_deployments(registry)]
return [h for h in hosts if h]
def generate_tunnel_config(registry: NodeRegistry) -> str | None:
@@ -62,7 +83,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
removes any stale config and leaves the tunnel down.
"""
node = registry.node
if not (node.tunnel_id and node.public_domain and node.gateway_domain):
# A public deployment needs a tunnel + an internal host to bridge to; the
# node-wide public_domain is only the *default* public name, so it isn't
# required (a deployment may carry its own public_host override instead).
if not (node.tunnel_id and node.gateway_domain):
return None
pubs = public_deployments(registry)
if not pubs:
@@ -70,7 +94,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
ingress: list[dict] = []
for _name, d in pubs:
public_host = f"{d.subdomain}.{node.public_domain}"
public_host = public_fqdn(d, node)
if not public_host:
# public but no override and no default public domain — nothing to map.
continue
internal_host = f"{d.subdomain}.{node.gateway_domain}"
ingress.append(
{
@@ -84,6 +111,9 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
},
}
)
if not ingress:
# Every public deployment was skipped (no override, no default domain).
return None
# Cloudflared requires a terminal catch-all; anything unmapped is refused.
ingress.append({"service": "http_status:404"})

View File

@@ -33,6 +33,28 @@ class Reach(str, Enum):
PUBLIC = "public"
def _validate_public_host(host: str | None, reach: Reach) -> None:
"""Validate an optional ``public_host`` override on an exposable deployment.
A ``public_host`` only makes sense for a publicly-projected deployment, and
must be a bare hostname (no scheme, path, port, or whitespace) — it becomes a
tunnel ingress ``hostname`` and a Caddy site address verbatim.
"""
if host is None:
return
if reach != Reach.PUBLIC:
raise ValueError(
f"public_host is only valid with reach: public (got reach: {reach.value})"
)
bad = any(c.isspace() for c in host) or any(
tok in host for tok in ("://", "/", ":")
)
if not host or host != host.strip(".") or bad:
raise ValueError(
f"public_host must be a bare hostname (e.g. example.com), got {host!r}"
)
# ---------------------
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
# ---------------------
@@ -263,13 +285,14 @@ class Requirement(BaseModel):
requirement, never scraped back into it.
A deployment declares these in its ``requires`` list; ``kind`` defaults to
``deployment`` (write just ``- ref: foo``). The ``system`` kind is not written
here — a program's host-package preconditions live in ``system_dependencies``,
and the relationship model synthesizes ``kind: system`` requirements from it for
the ``functional?`` check. See docs/relationships.md.
``deployment`` (write just ``- ref: foo``). The ``system`` and ``tool`` kinds are
not written here — a program's host-package preconditions live in
``system_dependencies`` (synthesized as ``kind: system``), and its stack's
toolchains (``uv``/``pnpm``/``hugo``/…) are synthesized as ``kind: tool`` — both by
the relationship model for the ``functional?`` check. See docs/relationships.md.
"""
kind: Literal["system", "deployment"] = "deployment"
kind: Literal["system", "deployment", "tool"] = "deployment"
ref: str
bind: str | None = None
@@ -419,6 +442,11 @@ class SystemdDeployment(DeploymentBase):
expose: ExposeSpec | None = None
# How far this process is exposed (off | internal | public). See `Reach`.
reach: Reach = Reach.OFF
# Optional public hostname override (an exact FQDN, e.g. `api.example.com` or an
# apex `example.com`). Only meaningful with `reach: public`; when set it is the
# public-facing name instead of the derived `<name>.<gateway.public_domain>`.
# Unset → the node-wide public domain is the default. See docs/tunnel-setup.md.
public_host: str | None = None
manage: ManageSpec | None = None
@model_validator(mode="after")
@@ -447,6 +475,7 @@ class SystemdDeployment(DeploymentBase):
"reach: public for a raw-TCP service isn't supported yet "
"(see docs/tcp-exposure.md step 5); use reach: internal"
)
_validate_public_host(self.public_host, self.reach)
return self
# Derived, read-only back-compat accessors (not serialized) so existing
@@ -487,11 +516,15 @@ class CaddyDeployment(DeploymentBase):
# A static site is inherently served at its subdomain, so `reach` is
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL
# Optional public hostname override (exact FQDN, apex allowed). Only meaningful
# with `reach: public`; see SystemdDeployment.public_host.
public_host: str | None = None
@model_validator(mode="after")
def _validate_reach(self) -> CaddyDeployment:
if self.reach == Reach.OFF:
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
_validate_public_host(self.public_host, self.reach)
return self
@property
@@ -506,6 +539,11 @@ class PathDeployment(DeploymentBase):
"""
manager: Literal["path"]
# An Anthropic Messages-API tool definition ({name, description, input_schema})
# for handing this CLI to an agent. Derived from the tool's ``--help`` (see
# ``castle_core.tool_schema``) but editable/overridable — a stored draft the
# completion-context builder reads. None → not yet generated.
tool_schema: dict | None = None
class RemoteDeployment(DeploymentBase):

View File

@@ -83,6 +83,9 @@ class Deployment:
# Also projected to the public internet via the tunnel at
# <subdomain>.<gateway.public_domain>. Requires subdomain.
public: bool = False
# Optional public hostname override (exact FQDN, apex allowed). When set it is
# the public-facing name instead of the derived <subdomain>.<public_domain>.
public_host: str | None = None
# Raw-TCP exposure port (postgres, redis, …). Set → reachable at
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
tcp_port: int | None = None
@@ -186,6 +189,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
health_path=comp_data.get("health_path"),
subdomain=comp_data.get("subdomain"),
public=comp_data.get("public", False),
public_host=comp_data.get("public_host"),
tcp_port=comp_data.get("tcp_port"),
static_root=comp_data.get("static_root"),
base_url=comp_data.get("base_url"),
@@ -263,6 +267,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["subdomain"] = comp.subdomain
if comp.public:
entry["public"] = comp.public
if comp.public_host:
entry["public_host"] = comp.public_host
if comp.tcp_port is not None:
entry["tcp_port"] = comp.tcp_port
if comp.static_root:

View File

@@ -16,6 +16,7 @@ not the reverse.
from __future__ import annotations
import os
import shutil
import subprocess
from collections import Counter
@@ -23,8 +24,10 @@ from dataclasses import dataclass, field
from pathlib import Path
from castle_core import git
from castle_core.config import CastleConfig
from castle_core.config import USER_TOOL_PATH_DIRS, CastleConfig
from castle_core.generators.systemd import runtime_path
from castle_core.manifest import Requirement, SystemdDeployment
from castle_core.stacks import ToolRequirement, tools_for
@dataclass
@@ -131,11 +134,12 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
"""The full requirement set for a deployment: its own ``requires`` (deployment
dependencies) plus its program's ``system_dependencies`` synthesized as
``kind: system`` requirements, de-duplicated by (kind, ref)."""
dependencies), its program's ``system_dependencies`` synthesized as
``kind: system`` requirements, and its stack's toolchains synthesized as
``kind: tool`` requirements — de-duplicated by (kind, ref)."""
reqs: list[Requirement] = []
# A bare name may span kinds — union their requirements (plus their program's
# host-package deps as the synthesized `kind: system` set).
# A bare name may span kinds — union their requirements (plus each program's
# host-package deps as `kind: system` and its stack's toolchains as `kind: tool`).
for _kind, dep in config.deployments_named(dep_name):
reqs += list(getattr(dep, "requires", []) or [])
prog = config.programs.get(_program_of(dep_name, dep))
@@ -143,6 +147,9 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
reqs += [
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
]
reqs += [
Requirement(kind="tool", ref=t.command) for t in tools_for(prog.stack)
]
seen: set[tuple[str, str]] = set()
out: list[Requirement] = []
for r in reqs:
@@ -152,6 +159,20 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
return out
def stack_tools_of(config: CastleConfig, dep_name: str) -> dict[str, ToolRequirement]:
"""command → its :class:`ToolRequirement`, for every stack toolchain the
deployment(s) named ``dep_name`` need. The metadata (phase, install hint) behind
the ``kind: tool`` requirements ``requirements_of`` synthesizes — used to check
them (phase picks the PATH) and to hint a fix."""
meta: dict[str, ToolRequirement] = {}
for _kind, dep in config.deployments_named(dep_name):
prog = config.programs.get(_program_of(dep_name, dep))
if prog and prog.stack:
for t in tools_for(prog.stack):
meta.setdefault(t.command, t)
return meta
def _dpkg_installed(pkg: str) -> bool:
"""Whether a dpkg package is installed (the authoritative 'installed' check on
Debian/Ubuntu). Returns False where dpkg isn't available."""
@@ -162,8 +183,41 @@ def _dpkg_installed(pkg: str) -> bool:
return r.returncode == 0 and "install ok installed" in r.stdout
def _check(config: CastleConfig, req: Requirement) -> bool:
"""Is a single requirement satisfied? (The check is fixed by its kind.)"""
def _build_path() -> str:
"""The PATH a *build/dev* verb runs with — the caller's own PATH plus the user
tool dirs (mirrors ``stacks._build_env``, minus the per-program node pin resolved
separately). Build-phase tools (pnpm, hugo, node) are checked against this."""
dirs = [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
return ":".join([*dirs, os.environ.get("PATH", "")])
def _tool_available(dep: object, tool: ToolRequirement) -> bool:
"""Is a stack tool present *where the deployment needs it*? A ``run``/``both``
tool of a systemd service must be on the **service's runtime PATH** (the curated
unit PATH, which can differ from your shell — the drift this catches); every
other case (build-only tools, or a non-service deployment like a static site) is
checked against the build/dev PATH."""
runs_service = isinstance(dep, SystemdDeployment)
if tool.phase in ("run", "both") and runs_service:
defaults = getattr(dep, "defaults", None)
env_path = (defaults.env.get("PATH") if defaults else None) or None
# `path_prepend` (resolved toolchain) lives on the registry deployment, not
# the manifest one; absent here it's the base runtime PATH, which is what a
# service without a pinned toolchain actually runs with.
path = env_path or runtime_path(list(getattr(dep, "path_prepend", []) or ()))
return shutil.which(tool.command, path=path) is not None
return shutil.which(tool.command, path=_build_path()) is not None
def _check(
config: CastleConfig,
req: Requirement,
dep: object | None = None,
tool: ToolRequirement | None = None,
) -> bool:
"""Is a single requirement satisfied? The check is fixed by its ``kind``. For a
``tool`` requirement, ``dep`` and ``tool`` supply the deployment context and the
stack-tool metadata (phase decides which PATH to probe)."""
if req.kind == "system":
# `system_dependencies` holds PACKAGE names, not executables. A PATH lookup
# only coincides with 'installed' when the package name equals its command
@@ -173,9 +227,24 @@ def _check(config: CastleConfig, req: Requirement) -> bool:
return shutil.which(req.ref) is not None or _dpkg_installed(req.ref)
if req.kind == "deployment":
return bool(config.deployments_named(req.ref))
if req.kind == "tool":
# No metadata (unknown stack) → don't raise a false alarm.
return tool is None or _tool_available(dep, tool)
return True
def hint_for(req: Requirement, tool: ToolRequirement | None = None) -> str:
"""A copyable next step for an *unmet* requirement — the piece that makes a
diagnostic actionable. ``tool`` carries a stack tool's precise install command."""
if req.kind == "tool":
return tool.install_hint if tool else f"install {req.ref}"
if req.kind == "system":
return f"sudo apt install {req.ref}"
if req.kind == "deployment":
return f"create & apply the '{req.ref}' deployment"
return ""
# Well-known TCP ports → a friendlier protocol label (display heuristic only).
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
@@ -229,11 +298,12 @@ def build_model(
nodes: list[Node] = []
for _nk, name, dep in config.all_deployments():
tmeta = stack_tools_of(config, name) if check else {}
unmet = (
[
f"{r.kind}:{r.ref}"
for r in requirements_of(config, name)
if not _check(config, r)
if not _check(config, r, dep=dep, tool=tmeta.get(r.ref))
]
if check
else []

View File

@@ -0,0 +1,136 @@
"""Stack dependency status — the derived, per-stack health the `castle stack`
command, the ``GET /stacks`` API, and the dashboard Stacks page all render.
A *stack* declares the host toolchains it needs (``stacks.ToolRequirement``); this
module answers, for each stack: which programs/deployments use it, whether its
tools are present *where the using deployments need them* (run-phase tools against
a service's runtime PATH — the drift the plain ``which`` a shell does misses), and
the copyable fix when one is missing. It's the single source of truth so the CLI,
API, and UI never disagree.
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass, field
from castle_core.config import CastleConfig
from castle_core.generators.systemd import runtime_path
from castle_core.relations import _build_path, _tool_available
from castle_core.stacks import ToolRequirement, available_stacks, get_handler, tools_for
@dataclass
class ToolStatus:
command: str
purpose: str
phase: str # run | build | both
present: bool # resolvable where the using deployments need it
install_hint: str # copyable fix (shown when absent)
version: str | None = None # best-effort `--version`, when present
@dataclass
class StackStatus:
name: str
tools: list[ToolStatus] = field(default_factory=list)
programs: list[str] = field(default_factory=list) # programs on this stack
deployments: list[str] = field(default_factory=list) # their deployments
verbs: list[str] = field(default_factory=list) # dev verbs the stack provides
has_enabled_deployment: bool = False # ≥1 enabled deployment uses it
@property
def in_use(self) -> bool:
return bool(self.programs)
@property
def ok(self) -> bool:
"""All needed tools present (vacuously true for a stack with no tools)."""
return all(t.present for t in self.tools)
def _version(command: str, path: str) -> str | None:
"""Best-effort tool version — the first `<cmd> --version` line (falls back to
`<cmd> version` for tools like hugo). Never raises; returns None on any trouble."""
exe = shutil.which(command, path=path)
if not exe:
return None
for argv in ([exe, "--version"], [exe, "version"]):
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=2)
except (OSError, subprocess.SubprocessError):
continue
out = (r.stdout or r.stderr or "").strip()
if r.returncode == 0 and out:
return out.splitlines()[0].strip()
return None
def _tool_status(
tool: ToolRequirement,
using_deps: list[object],
*,
with_version: bool,
) -> ToolStatus:
# Present where it's needed: a run/both tool must resolve for EVERY using
# deployment (a service's runtime PATH); with no deployments, check generically.
if using_deps:
present = all(_tool_available(dep, tool) for dep in using_deps)
elif tool.phase in ("run", "both"):
present = shutil.which(tool.command, path=runtime_path()) is not None
else:
present = shutil.which(tool.command, path=_build_path()) is not None
# A version is only meaningful when the tool is present; probe the path that
# matched (runtime for run/both, build otherwise) so it reflects what's used.
probe_path = runtime_path() if tool.phase in ("run", "both") else _build_path()
version = _version(tool.command, probe_path) if (present and with_version) else None
return ToolStatus(
command=tool.command,
purpose=tool.purpose,
phase=tool.phase,
present=present,
install_hint=tool.install_hint,
version=version,
)
def stack_status(
config: CastleConfig, name: str, *, with_version: bool = True
) -> StackStatus | None:
"""The dependency status of one stack, or None if castle has no such handler."""
handler = get_handler(name)
if handler is None:
return None
programs = sorted(p for p, c in config.programs.items() if c.stack == name)
deps: list[tuple[str, object]] = [] # (deployment-name, spec)
enabled = False
for _kind, dep_name, dep in config.all_deployments():
prog = config.programs.get(dep.program or dep_name)
if prog and prog.stack == name:
deps.append((dep_name, dep))
enabled = enabled or getattr(dep, "enabled", True)
dep_specs = [d for _n, d in deps]
return StackStatus(
name=name,
tools=[
_tool_status(t, dep_specs, with_version=with_version)
for t in tools_for(name)
],
programs=programs,
deployments=sorted(n for n, _d in deps),
verbs=sorted(handler.provides),
has_enabled_deployment=enabled,
)
def all_stack_status(
config: CastleConfig, *, with_version: bool = True
) -> list[StackStatus]:
"""Dependency status for every stack castle knows — the Stacks catalog."""
out = []
for name in available_stacks():
st = stack_status(config, name, with_version=with_version)
if st is not None:
out.append(st)
return out

View File

@@ -42,6 +42,30 @@ class ActionResult:
output: str = ""
@dataclass(frozen=True)
class ToolRequirement:
"""A host toolchain a stack needs — the declarative counterpart to the argv each
handler hard-codes (``uv sync``, ``pnpm build``, …). Made explicit so castle can
*check* whether the tool is present (on the box and, for run-phase tools, on the
service's own PATH) and, when it isn't, show a copyable fix instead of failing
mid-subprocess with a raw ``command not found``.
- ``phase`` — when the tool is needed. ``build`` tools run only at
``castle apply``/build time (checked against the build env); ``run`` tools must
also be on the *running service's* PATH (the curated systemd env, which can
drift from your shell); ``both`` is checked in both places.
- ``install_hint`` — the exact command a user can copy to install it.
"""
command: (
str # the executable, e.g. "uv" | "pnpm" | "node" | "hugo" | "deno" | "psql"
)
purpose: str # human phrase, e.g. "build & run Python programs"
phase: str # "run" | "build" | "both"
install_hint: str # copyable install command
version_min: str | None = None
def _build_env(node_source: Path | None = None) -> dict[str, str]:
"""Build a subprocess env with user tool dirs on PATH.
@@ -111,6 +135,19 @@ class StackHandler:
# stacks whose `teardown` actually destroys something.
owns_data: bool = False
# The dev verbs this stack advertises — what `available_actions` offers and
# `run_action` will dispatch. Defaults to every stack verb (the python/react/
# supabase handlers implement them all); a narrow stack like hugo (build-only,
# no native lint/test/type-check) overrides this so callers aren't offered verbs
# that would only error. `check` composes the sub-verbs, so a handler that drops
# lint/type-check/test also drops `check` implicitly.
provides: set[str] = _STACK_VERBS
# The host toolchains this stack needs, declared so castle can check them and
# hint a fix. Empty by default (a stackless / declared-command program depends on
# nothing castle can name); each real handler overrides it. See `tools_for`.
tools: tuple[ToolRequirement, ...] = ()
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
@@ -170,6 +207,15 @@ class StackHandler:
class PythonHandler(StackHandler):
"""Handler for python-cli and python-fastapi stacks."""
tools = (
ToolRequirement(
command="uv",
purpose="build & run Python programs",
phase="both",
install_hint="curl -LsSf https://astral.sh/uv/install.sh | sh",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["uv", "sync"], src)
@@ -278,6 +324,24 @@ def _pnpm(*args: str) -> list[str]:
class ReactViteHandler(StackHandler):
"""Handler for react-vite stack."""
# Build-phase only: the output is a static `dist/` the gateway serves in place,
# so no node/pnpm process runs at serve time. node is resolved per-program from
# its pin (see toolchains); the hint names nvm, the ecosystem-standard installer.
tools = (
ToolRequirement(
command="node",
purpose="build the frontend (Vite/React toolchain)",
phase="build",
install_hint="nvm install --lts # or match the program's .node-version",
),
ToolRequirement(
command="pnpm",
purpose="install deps & run the Vite build",
phase="build",
install_hint="npm install -g pnpm # or: corepack enable pnpm",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
# Build against the gateway serve root so absolute asset URLs resolve at /
@@ -374,6 +438,67 @@ class ReactViteHandler(StackHandler):
)
class HugoHandler(StackHandler):
"""Handler for the hugo stack — a static site built by the Hugo generator.
Hugo has one real dev verb: **build** (`hugo --gc --minify` → `public/`), which
a `caddy` deployment then serves in place at `<name>.<gateway.domain>`. There is
no native lint/test/type-check, so `provides` is narrowed to the verbs that do
something. A theme with an asset pipeline (e.g. Blowfish + Tailwind) declares its
own two-step `build.commands` (`pnpm build` then `hugo …`), which override this
default per the usual declared-command-wins resolution."""
provides = {"build", "install", "uninstall"}
tools = (
ToolRequirement(
command="hugo",
purpose="build the static site",
phase="build",
install_hint="sudo apt install hugo # or: snap install hugo",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["hugo", "--gc", "--minify"], src)
return ActionResult(
program=name,
action="build",
status="ok" if rc == 0 else "error",
output=output,
)
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Build the site in place. The gateway serves it directly from
<source>/<build.outputs[0]> (default `public/`) — no copy step."""
result = await self.build(name, comp, root)
if result.status != "ok":
return ActionResult(
program=name,
action="install",
status="error",
output=f"Build failed:\n{result.output}",
)
outputs = comp.build.outputs if comp.build else []
dist = _source_dir(comp, root) / (outputs[0] if outputs else "public")
return ActionResult(
program=name,
action="install",
status="ok",
output=f"Built; served in place from {dist}",
)
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Static sites have no install footprint — served in place. Deactivating one
means dropping its gateway route, not deleting build output."""
return ActionResult(
program=name,
action="uninstall",
status="ok",
output=f"{name}: served in place; nothing to uninstall.",
)
def _migration_version(path: Path) -> str:
"""The version key of a migration file — the leading token before '_'.
@@ -466,6 +591,20 @@ class SupabaseHandler(StackHandler):
"""
owns_data = True
tools = (
ToolRequirement(
command="psql",
purpose="run schema migrations against the substrate DB",
phase="both",
install_hint="sudo apt install postgresql-client",
),
ToolRequirement(
command="deno",
purpose="serve/deploy edge functions",
phase="both",
install_hint="curl -fsSL https://deno.land/install.sh | sh",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Apply unapplied migrations into the app's own schema (idempotent)."""
@@ -655,6 +794,7 @@ HANDLERS: dict[str, StackHandler] = {
"python-fastapi": PythonHandler(),
"react-vite": ReactViteHandler(),
"supabase": SupabaseHandler(),
"hugo": HugoHandler(),
}
@@ -672,6 +812,14 @@ def available_stacks() -> list[str]:
return sorted(HANDLERS)
def tools_for(stack: str | None) -> tuple[ToolRequirement, ...]:
"""The host toolchains a stack declares it needs (empty for an unknown/absent
stack). The single source of truth for the dependency checks in `relations`,
`castle stack`, `castle doctor`, and the dashboard Stacks page."""
handler = get_handler(stack)
return handler.tools if handler is not None else ()
def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
"""Declared argv-lists for a verb, or None.
@@ -687,12 +835,12 @@ def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
def _stack_provides(comp: ProgramSpec, verb: str) -> bool:
"""Whether the program's stack handler can run this verb."""
return (
bool(comp.source)
and verb in _STACK_VERBS
and get_handler(comp.stack) is not None
)
"""Whether the program's stack handler advertises this verb.
Consults the handler's ``provides`` set (default: all of ``_STACK_VERBS``) so a
build-only stack like hugo doesn't offer lint/test/type-check it can't run."""
handler = get_handler(comp.stack)
return bool(comp.source) and handler is not None and verb in handler.provides
def is_available(comp: ProgramSpec, verb: str) -> bool:

View File

@@ -0,0 +1,393 @@
"""Derive LLM tool-call schemas from a CLI tool's ``--help``.
castle's in-process version of the standalone ``toolify`` tool: given a tool (a
program with a ``path`` deployment), resolve its executable, run ``--help``, and
build a tool-call definition.
Two parameter shapes, chosen automatically:
* **structured** — one typed property per option/positional, parsed from a
standard argparse/click ``--help``, each carrying an ``x-cli`` executor hint.
Used when the help is recognizable *and* the tool has no subcommands.
* **command** — a single ``command`` string, full ``--help`` as description. The
universal fallback for non-standard help (``jq``) and subcommand trees.
Schemas are built and stored in a **neutral** core (``{name, description,
parameters}``); ``render_tool_schema`` wraps that in the Anthropic or OpenAI
envelope on read. castle's feed defaults to OpenAI (litellm-native).
The extraction is intentionally duplicated from ``toolify`` rather than shared —
``toolify`` is a standalone program that must never depend on castle. No LLM is
used; the output is a deterministic function of the tool's ``--help``.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from castle_core.config import CastleConfig
__all__ = [
"ToolSchemaError",
"derive_tool_schema",
"render_tool_schema",
"tool_executable",
]
_NAME_OK = re.compile(r"[^a-zA-Z0-9_-]")
_HELP_TIMEOUT = 10
_MAX_SUBCOMMANDS = 40
_SKIP_OPTS = {"help", "version"}
_CMD_HEADING = re.compile(
r"^\s*(commands|subcommands|available commands)\s*:?\s*$", re.IGNORECASE
)
_OPT_HEADING = re.compile(r"^\s*(options|optional arguments)\s*:?\s*$", re.IGNORECASE)
_POS_HEADING = re.compile(r"^\s*positional arguments\s*:?\s*$", re.IGNORECASE)
class ToolSchemaError(Exception):
"""The tool's executable couldn't be resolved or produced no help."""
def _sanitize_name(raw: str) -> str:
name = _NAME_OK.sub("_", raw).strip("_") or "tool"
return name[:64]
def _run_help(argv: list[str]) -> tuple[int | None, str]:
"""Run ``argv + ['--help']`` → ``(returncode, help_text)``; never raises."""
for flag in ("--help", "-h"):
try:
proc = subprocess.run(
[*argv, flag],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=_HELP_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError):
continue
text = proc.stdout.strip() or proc.stderr.strip()
if text:
return proc.returncode, text
return None, ""
def _section_entries(help_text: str, heading_re: re.Pattern[str]) -> list[list[str]]:
"""Entries (each a list of lines) under headings matching ``heading_re``. A
row at the section's minimum indent starts an entry; deeper rows continue it."""
out: list[list[str]] = []
lines = help_text.splitlines()
i = 0
while i < len(lines):
if not heading_re.match(lines[i]):
i += 1
continue
i += 1
body: list[str] = []
while i < len(lines) and (not lines[i].strip() or lines[i][:1].isspace()):
if lines[i].strip():
body.append(lines[i])
i += 1
if not body:
continue
term = min(len(ln) - len(ln.lstrip()) for ln in body)
cur: list[str] = []
for ln in body:
if (len(ln) - len(ln.lstrip())) == term:
if cur:
out.append(cur)
cur = [ln]
else:
cur.append(ln)
if cur:
out.append(cur)
return out
def _extract_subcommands(help_text: str) -> list[str]:
"""Subcommand names, or [] for a flat tool. Only click ``Commands:`` entries
and argparse ``{a,b,c}`` choice rows count — a plain positional does not."""
cmds: list[str] = []
for entry in _section_entries(help_text, _CMD_HEADING):
word = re.match(r"^([A-Za-z][\w-]*)\b", entry[0].strip())
if word:
cmds.append(word.group(1))
for entry in _section_entries(help_text, _POS_HEADING):
choice = re.match(r"^\{([^}]+)\}", entry[0].strip())
if choice:
cmds.extend(p.strip() for p in choice.group(1).split(","))
seen: list[str] = []
for c in cmds:
if c and c not in seen:
seen.append(c)
return seen
def _entry_head_and_desc(entry: list[str]) -> tuple[str, str]:
m = re.match(r"^\s*(.*?)(?:\s{2,}(.*))?$", entry[0])
head = (m.group(1) if m else entry[0]).strip()
desc_parts = [m.group(2)] if m and m.group(2) else []
desc_parts += [ln.strip() for ln in entry[1:]]
return head, " ".join(p for p in desc_parts if p).strip()
def _parse_option(entry: list[str]) -> tuple[str, dict] | None:
head, desc = _entry_head_and_desc(entry)
flags: list[str] = []
metavar: str | None = None
for tok in head.split(", "):
fm = re.match(r"^(-{1,2}[\w-]+)(?:[ =](.+))?$", tok.strip())
if not fm:
continue
flags.append(fm.group(1))
if fm.group(2):
metavar = fm.group(2).strip()
longs = [f for f in flags if f.startswith("--")]
canonical = longs[-1] if longs else (flags[0] if flags else None)
if not canonical:
return None
key = canonical.lstrip("-").replace("-", "_")
if key in _SKIP_OPTS:
return None
prop: dict = {}
if metavar and metavar.startswith("{") and metavar.endswith("}"):
prop["type"] = "string"
prop["enum"] = [v.strip() for v in metavar[1:-1].split(",")]
elif metavar:
prop["type"] = "string"
else:
prop["type"] = "boolean"
if desc:
prop["description"] = desc
prop["x-cli"] = {"flag": canonical, "value": bool(metavar)}
return key, prop
def _parse_positional(entry: list[str], order: int) -> tuple[str, dict] | None:
head, desc = _entry_head_and_desc(entry)
if head.startswith("{"):
return None
nm = re.match(r"^([A-Za-z][\w-]*)", head)
if not nm:
return None
key = nm.group(1).replace("-", "_")
prop: dict = {"type": "string"}
if desc:
prop["description"] = desc
prop["x-cli"] = {"positional": True, "order": order}
return key, prop
def _summary(help_text: str, fallback: str) -> str:
for para in re.split(r"\n\s*\n", help_text):
p = para.strip()
if not p or p.lower().startswith("usage:") or p.rstrip().endswith(":"):
continue
return " ".join(p.split())
return fallback
def _structured_core(name: str, help_text: str) -> dict | None:
props: dict = {}
required: list[str] = []
for order, entry in enumerate(_section_entries(help_text, _POS_HEADING)):
parsed = _parse_positional(entry, order)
if parsed:
key, prop = parsed
props[key] = prop
required.append(key)
for entry in _section_entries(help_text, _OPT_HEADING):
parsed = _parse_option(entry)
if parsed:
key, prop = parsed
props[key] = prop
if not props:
return None
parameters: dict = {"type": "object", "properties": props}
if required:
parameters["required"] = required
return {
"name": _sanitize_name(name),
"description": _summary(help_text, f"Run the `{name}` command-line tool."),
"parameters": parameters,
}
def _command_core(name: str, argv: list[str], help_text: str, deep: bool) -> dict:
parts = [
f"Run the `{name}` command-line tool. Provide the arguments in the "
f"`command` parameter; the executable name is prepended automatically. "
f"Below is the tool's help output.",
f"\n$ {name} --help\n{help_text}",
]
if deep:
for sub in _extract_subcommands(help_text)[:_MAX_SUBCOMMANDS]:
rc, sub_help = _run_help([*argv, sub])
if rc == 0 and sub_help:
parts.append(f"\n$ {name} {sub} --help\n{sub_help}")
return {
"name": _sanitize_name(name),
"description": "\n".join(parts),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": (
f"Arguments to pass to `{name}`. The full command line run "
f"is `{name} ` followed by this string. Do not include the "
f"leading `{name}`."
),
}
},
"required": ["command"],
},
}
def tool_executable(config: CastleConfig, name: str) -> str:
"""The console script to invoke for tool ``name`` — its first
``[project.scripts]`` key (source of truth even when uninstalled), else the
program name. Mirrors the CLI's tools lens."""
comp = config.programs.get(name)
src = getattr(comp, "source", None) if comp else None
if src:
pyproject = Path(src) / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
scripts = data.get("project", {}).get("scripts", {})
if scripts:
return sorted(scripts.keys())[0]
except (OSError, tomllib.TOMLDecodeError):
pass
return name
def collect_tool_help(config: CastleConfig, name: str) -> str:
"""The full recursive ``--help`` text for tool ``name`` — top-level plus each
subcommand's help, the raw material an LLM assist reads to structure a
subcommand tree. Deterministic; raises ``ToolSchemaError`` if unresolved.
"""
exe_name = tool_executable(config, name)
exe = shutil.which(exe_name)
if exe is None:
raise ToolSchemaError(
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
f"`castle apply`) before generating its schema."
)
_, help_text = _run_help([exe])
if not help_text:
raise ToolSchemaError(f"`{exe_name} --help` produced no output.")
parts = [f"$ {exe_name} --help\n{help_text}"]
for sub in _extract_subcommands(help_text)[:_MAX_SUBCOMMANDS]:
rc, sub_help = _run_help([exe, sub])
if rc == 0 and sub_help:
parts.append(f"\n$ {exe_name} {sub} --help\n{sub_help}")
return "\n".join(parts)
def validate_tool_schema_core(core: object) -> list[str]:
"""Deterministically validate a neutral tool-call core.
Returns a list of human-readable error strings (empty ⇒ valid). Checks the
``{name, description, parameters}`` shape *and* that ``parameters`` is a valid
JSON Schema (via ``jsonschema`` meta-validation, which catches malformed
properties like an ``enum`` that isn't a list — the defect weak models hit).
Shared by the LLM repair loop, the ``validate`` endpoint, and the accept gate.
"""
errors: list[str] = []
if not isinstance(core, dict):
return ["schema must be a JSON object"]
name = core.get("name")
if not isinstance(name, str) or not name:
errors.append("`name` must be a non-empty string")
elif not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", name):
errors.append("`name` must match ^[a-zA-Z0-9_-]{1,64}$")
if not isinstance(core.get("description"), str):
errors.append("`description` must be a string")
params = core.get("parameters")
if not isinstance(params, dict):
errors.append("`parameters` must be an object")
return errors
if params.get("type") != "object":
errors.append('`parameters.type` must be "object"')
props = params.get("properties")
if not isinstance(props, dict):
errors.append("`parameters.properties` must be an object")
elif not props:
errors.append("`parameters.properties` is empty — no arguments captured")
try:
import jsonschema
from jsonschema.exceptions import SchemaError
try:
jsonschema.Draft202012Validator.check_schema(params)
except SchemaError as e:
loc = "/".join(str(p) for p in e.absolute_path) or "parameters"
errors.append(f"invalid JSON Schema at `{loc}`: {e.message}")
except ImportError: # jsonschema not installed — structural checks stand alone
pass
return errors
def is_tool_schema_core(obj: object) -> bool:
"""True if ``obj`` is a valid neutral tool-call core (no validation errors)."""
return not validate_tool_schema_core(obj)
def derive_tool_schema(config: CastleConfig, name: str, deep: bool = False) -> dict:
"""Derive the neutral tool-call core for tool ``name`` from its ``--help``.
Structured params when the help is standard and flat; the command-string
fallback for non-standard help / subcommand trees. Raises ``ToolSchemaError``
if the executable isn't on PATH or emits no help.
"""
exe_name = tool_executable(config, name)
exe = shutil.which(exe_name)
if exe is None:
raise ToolSchemaError(
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
f"`castle apply`) before generating its schema."
)
_, help_text = _run_help([exe])
if not help_text:
raise ToolSchemaError(f"`{exe_name} --help` produced no output.")
if not _extract_subcommands(help_text):
structured = _structured_core(exe_name, help_text)
if structured:
return structured
return _command_core(exe_name, [exe], help_text, deep)
def render_tool_schema(core: dict, fmt: str = "openai") -> dict:
"""Wrap a neutral core in a provider envelope.
``openai`` (default, litellm-native) → ``{type: function, function: {…}}``;
``anthropic`` → ``{name, description, input_schema}``; ``neutral`` → as stored.
"""
if fmt == "neutral":
return core
if fmt == "anthropic":
return {
"name": core["name"],
"description": core["description"],
"input_schema": core["parameters"],
}
return {
"type": "function",
"function": {
"name": core["name"],
"description": core["description"],
"parameters": core["parameters"],
},
}

View File

@@ -11,10 +11,31 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from castle_core.deploy import _render_unit_preview, apply
import castle_core.deploy as deploy_mod
from castle_core.config import load_config
from castle_core.deploy import (
_desired_registry,
_gateway_would_change,
_render_unit_preview,
apply,
generate_caddyfile_from_registry,
)
from castle_core.registry import Deployment
def _add_static(castle_root: Path, name: str = "test-static") -> None:
"""Write a caddy (static) program + deployment into an existing castle root."""
(castle_root / "programs" / f"{name}.yaml").write_text(
f"description: Static {name}\nsource: {castle_root / name}\n"
)
statics = castle_root / "deployments" / "statics"
statics.mkdir(parents=True, exist_ok=True)
(statics / f"{name}.yaml").write_text(
f"program: {name}\nmanager: caddy\nroot: public\nreach: internal\n"
)
(castle_root / name / "public").mkdir(parents=True, exist_ok=True)
def _plan(castle_root: Path, active: dict[str, bool]):
"""Run apply(plan=True) with is_active stubbed to `active` (default False)."""
with patch(
@@ -68,6 +89,49 @@ class TestApplyPlan:
assert result.changed is True
class TestGatewayChange:
"""A caddy route change touches no systemd unit, so the activate/restart/
deactivate reconcile can't see it. `gateway_changed` catches it by diffing the
would-be Caddyfile/tunnel config against disk — otherwise a new/changed static
route reports a false 'already converged'.
SPECS_DIR is the real ~/.castle path (unpatched by the fixtures), so these
redirect it to a temp dir to stay hermetic and never touch the live Caddyfile.
"""
def test_new_route_reports_gateway_changed(
self, castle_root: Path, tmp_path: Path, monkeypatch
) -> None:
"""A static whose route isn't on disk yet → gateway_changed, even when the
assets already exist so the deployment itself classifies 'unchanged'."""
monkeypatch.setattr(deploy_mod, "SPECS_DIR", tmp_path / "specs")
_add_static(castle_root)
# Static is 'active' (built) → _classify buckets it 'unchanged'; the route is
# still absent from the (missing) Caddyfile, so the gateway did change.
result = _plan(castle_root, active={"test-static": True})
assert "test-static" in result.unchanged
assert result.gateway_changed is True
assert result.changed is True
def test_converged_caddyfile_is_not_changed(
self, castle_root: Path, tmp_path: Path, monkeypatch
) -> None:
"""When the on-disk Caddyfile already matches the desired one, no change."""
specs = tmp_path / "specs"
specs.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(deploy_mod, "SPECS_DIR", specs)
_add_static(castle_root)
config = load_config(castle_root)
(specs / "Caddyfile").write_text(
generate_caddyfile_from_registry(_desired_registry(config, None))
)
assert _gateway_would_change(config, None) is False
def test_render_unit_preview_none_for_non_systemd() -> None:
"""Non-systemd managers have no unit file — preview is None (never 'restart').

View File

@@ -41,6 +41,7 @@ def _make_registry(
gateway_tls: str | None = None,
gateway_domain: str | None = None,
acme_email: str | None = None,
public_domain: str | None = None,
) -> NodeRegistry:
reg = NodeRegistry(
node=NodeConfig(
@@ -49,6 +50,7 @@ def _make_registry(
gateway_tls=gateway_tls,
gateway_domain=gateway_domain,
acme_email=acme_email,
public_domain=public_domain,
),
)
for name, d in (deployed or {}).items():
@@ -68,9 +70,14 @@ def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "p
)
def _acme(deployed: dict[str, Deployment], domain: str | None = "example.com") -> NodeRegistry:
def _acme(
deployed: dict[str, Deployment],
domain: str | None = "example.com",
public_domain: str | None = None,
) -> NodeRegistry:
return _make_registry(
gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", deployed=deployed
gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com",
public_domain=public_domain, deployed=deployed,
)
@@ -133,6 +140,52 @@ class TestAcmeMode:
assert "file_server" in cf
class TestPublicExposure:
"""Public deployments are served under the public zone; a `public_host`
override (apex / other zone) gets its own standalone site with its own cert."""
def _static_pub(self, name: str, public_host: str | None = None) -> Deployment:
return Deployment(
manager="caddy", run_cmd=[], subdomain=name,
static_root=f"/data/repos/{name}/public", public=True, public_host=public_host,
)
def test_default_public_uses_wildcard_site(self) -> None:
reg = _acme({"blog": self._static_pub("blog")}, public_domain="pub.example.org")
cf = generate_caddyfile_from_registry(reg)
assert "*.pub.example.org {" in cf
assert "@host_blog_pub host blog.pub.example.org" in cf
def test_public_host_override_is_standalone_apex_site(self) -> None:
reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")},
public_domain="pub.example.org")
cf = generate_caddyfile_from_registry(reg)
# An explicit apex site (not under the *.pub wildcard) so Caddy issues its
# own cert via DNS-01; file_server serves the same local dir directly.
assert "payne.io {" in cf
assert "root * /data/repos/payne-io/public" in cf
# The override host must NOT appear as a *.pub.example.org subdomain.
assert "payne-io.pub.example.org" not in cf
def test_override_and_default_coexist(self) -> None:
reg = _acme(
{"blog": self._static_pub("blog"),
"payne-io": self._static_pub("payne-io", "payne.io")},
public_domain="pub.example.org",
)
cf = generate_caddyfile_from_registry(reg)
assert "*.pub.example.org {" in cf # default wildcard block still present
assert "@host_blog_pub host blog.pub.example.org" in cf
assert "payne.io {" in cf # standalone apex site
def test_public_host_without_default_domain(self) -> None:
# No node-wide public_domain: only the override host gets a site.
reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")})
cf = generate_caddyfile_from_registry(reg)
assert "payne.io {" in cf
assert "*.None" not in cf
class TestOffMode:
"""No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api.
Other services are port-only (not routed)."""

110
core/tests/test_dns.py Normal file
View File

@@ -0,0 +1,110 @@
"""Tests for multi-zone public DNS (Cloudflare CNAME) reconciliation."""
from __future__ import annotations
import castle_core.generators.dns as dns
from castle_core.generators.dns import _zone_for, reconcile_public_dns
TID = "tid-abc"
TARGET = f"{TID}.cfargotunnel.com"
ZONES = [
{"id": "z_payne", "name": "payne.io"},
{"id": "z_ex", "name": "example.org"},
]
class _FakeCloudflare:
"""A minimal fake of the Cloudflare API used by reconcile_public_dns.
``records`` maps zone_id -> {name: (record_id, content)}. Records POST/DELETE
calls so tests can assert exactly what castle created/removed.
"""
def __init__(self, records: dict[str, dict[str, tuple[str, str]]]):
self.records = records
self.created: list[tuple[str, str]] = [] # (zone_id, name)
self.deleted: list[tuple[str, str]] = [] # (zone_id, record_id)
self._n = 0
def api(self, token: str, method: str, path: str, body: dict | None = None) -> dict:
if method == "GET" and path.startswith("/zones?"):
return {"result": ZONES}
if method == "GET" and "/dns_records" in path:
zone_id = path.split("/zones/")[1].split("/")[0]
recs = self.records.get(zone_id, {})
return {
"result": [
{"id": rid, "name": name, "content": content, "type": "CNAME"}
for name, (rid, content) in recs.items()
]
}
if method == "POST":
zone_id = path.split("/zones/")[1].split("/")[0]
assert body is not None
self.created.append((zone_id, body["name"]))
return {"result": {"id": f"new{self._n}"}}
if method == "DELETE":
zone_id = path.split("/zones/")[1].split("/")[0]
rid = path.rsplit("/", 1)[1]
self.deleted.append((zone_id, rid))
return {"result": {"id": rid}}
raise AssertionError(f"unexpected call {method} {path}")
def _run(fake: _FakeCloudflare, desired: list[str], monkeypatch) -> list[str]:
monkeypatch.setattr(dns, "_api", fake.api)
messages: list[str] = []
ok = reconcile_public_dns(TID, desired, messages, token="tok")
assert ok is True
return messages
def test_longest_suffix_routes_host_to_its_zone() -> None:
assert _zone_for("payne.io", ZONES)["id"] == "z_payne" # apex
assert _zone_for("api.payne.io", ZONES)["id"] == "z_payne" # subdomain
assert _zone_for("app.example.org", ZONES)["id"] == "z_ex"
assert _zone_for("nope.other.net", ZONES) is None # no visible zone
def test_creates_apex_and_subdomain_in_correct_zones(monkeypatch) -> None:
fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
_run(fake, ["payne.io", "app.example.org"], monkeypatch)
assert set(fake.created) == {("z_payne", "payne.io"), ("z_ex", "app.example.org")}
assert fake.deleted == []
def test_deletes_managed_cname_no_longer_desired(monkeypatch) -> None:
# z_payne has a stale castle-managed CNAME + a hand-managed one pointing elsewhere.
fake = _FakeCloudflare(records={
"z_payne": {
"payne.io": ("r1", TARGET), # castle-managed, still desired
"old.payne.io": ("r2", TARGET), # castle-managed, now stale → delete
"keep.payne.io": ("r3", "other.example.com"), # NOT ours → never touched
},
"z_ex": {},
})
_run(fake, ["payne.io"], monkeypatch)
assert fake.created == []
assert fake.deleted == [("z_payne", "r2")] # only the stale managed one
def test_empty_desired_cleans_all_managed(monkeypatch) -> None:
fake = _FakeCloudflare(records={
"z_payne": {"payne.io": ("r1", TARGET)},
"z_ex": {"a.example.org": ("r2", TARGET)},
})
_run(fake, [], monkeypatch)
assert set(fake.deleted) == {("z_payne", "r1"), ("z_ex", "r2")}
def test_no_token_returns_false(monkeypatch) -> None:
monkeypatch.setattr(dns, "public_dns_token", lambda: None)
assert reconcile_public_dns(TID, ["payne.io"], [], token=None) is False
def test_unresolvable_host_warns_but_still_reconciles_others(monkeypatch) -> None:
fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
msgs = _run(fake, ["payne.io", "x.unknown.net"], monkeypatch)
assert fake.created == [("z_payne", "payne.io")]
assert any("unknown.net" in m for m in msgs)

View File

@@ -176,6 +176,40 @@ class TestSystemdDeployment:
}
)
def test_public_host_override_accepted_with_reach_public(self) -> None:
base = dict(
manager="systemd",
run=RunCommand(launcher="command", argv=["x"]),
expose={"http": {"internal": {"port": 9001}}},
)
s = SystemdDeployment.model_validate(
{**base, "reach": "public", "public_host": "payne.io"}
)
assert s.public_host == "payne.io"
def test_public_host_requires_reach_public(self) -> None:
"""public_host without reach: public is a no-op; reject it at load."""
base = dict(
manager="systemd",
run=RunCommand(launcher="command", argv=["x"]),
expose={"http": {"internal": {"port": 9001}}},
)
with pytest.raises(ValueError, match="public_host is only valid"):
SystemdDeployment.model_validate(
{**base, "reach": "internal", "public_host": "payne.io"}
)
def test_public_host_must_be_bare_hostname(self) -> None:
base = dict(
manager="systemd",
run=RunCommand(launcher="command", argv=["x"]),
expose={"http": {"internal": {"port": 9001}}},
reach="public",
)
for bad in ("https://payne.io", "payne.io/path", "payne.io:443", "payne .io"):
with pytest.raises(ValueError, match="bare hostname"):
SystemdDeployment.model_validate({**base, "public_host": bad})
def test_no_run_is_invalid(self) -> None:
"""A systemd deployment requires a run (launch) spec."""
with pytest.raises(Exception):

View File

@@ -6,7 +6,13 @@ import pytest
import castle_core.config as C
from castle_core import relations as R
from castle_core.manifest import ProgramSpec, SystemdDeployment
from castle_core.manifest import (
CaddyDeployment,
ProgramSpec,
Requirement,
SystemdDeployment,
)
from castle_core.stacks import tools_for
def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
@@ -20,6 +26,12 @@ def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
return SystemdDeployment.model_validate(spec)
def _static(program: str) -> CaddyDeployment:
return CaddyDeployment.model_validate(
{"manager": "caddy", "program": program, "root": "dist"}
)
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
return C.CastleConfig(
root=None,
@@ -56,7 +68,11 @@ def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
"""A {kind: deployment} requirement becomes an edge (with bind), and the target's
fan-in is the count of distinct dependents."""
cfg = _cfg(
{"web": ProgramSpec(id="web"), "cli": ProgramSpec(id="cli"), "api": ProgramSpec(id="api")},
{
"web": ProgramSpec(id="web"),
"cli": ProgramSpec(id="cli"),
"api": ProgramSpec(id="api"),
},
{
"web": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]),
"cli": _dep("cli", requires=[{"ref": "api"}]),
@@ -107,3 +123,73 @@ def test_missing_deployment_requirement_is_unmet() -> None:
m = R.build_model(cfg, check=True)
web = next(n for n in m.nodes if n.name == "web")
assert web.unmet == ["deployment:ghost"] and web.functional is False
# --- stack toolchains as requirements ----------------------------------------
def test_stack_toolchain_surfaces_as_tool_requirement() -> None:
"""A program's stack contributes its toolchains as {kind: tool} requirements."""
prog = ProgramSpec(id="svc", stack="python-fastapi")
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
reqs = {(r.kind, r.ref) for r in R.requirements_of(cfg, "svc")}
assert ("tool", "uv") in reqs
def test_stack_tool_missing_from_service_path_is_unmet(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The drift case: uv resolves in the caller's shell PATH but NOT on the
service's curated runtime PATH → unmet *for the service*, even though a bare
`which` (what `castle tool list` uses) would report it present."""
prog = ProgramSpec(id="svc", stack="python-fastapi")
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
shell_only = "/home/me/.shell-tools" # a dir the runtime PATH never includes
monkeypatch.setenv("PATH", shell_only)
monkeypatch.setattr(
R.shutil,
"which",
lambda cmd, path=None: (
f"{shell_only}/{cmd}" if path and shell_only in path else None
),
)
m = R.build_model(cfg, check=True)
svc = next(n for n in m.nodes if n.name == "svc")
assert svc.unmet == ["tool:uv"] and svc.functional is False
def test_stack_tool_present_on_service_path_is_satisfied(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prog = ProgramSpec(id="svc", stack="python-fastapi")
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
monkeypatch.setattr(R.shutil, "which", lambda cmd, path=None: f"/usr/bin/{cmd}")
svc = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "svc")
assert svc.functional is True and "tool:uv" not in svc.unmet
def test_build_phase_tool_checked_against_build_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A static site's build-only tools (pnpm/node) are checked against the build/dev
PATH — a static deployment runs no process, so runtime-PATH drift doesn't apply."""
prog = ProgramSpec(id="ui", stack="react-vite")
cfg = _cfg({"ui": prog}, {"ui": _static("ui")})
dev_dir = "/home/me/.local/share/pnpm"
monkeypatch.setenv("PATH", dev_dir)
monkeypatch.setattr(
R.shutil,
"which",
lambda cmd, path=None: f"{dev_dir}/{cmd}" if path and dev_dir in path else None,
)
ui = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "ui")
assert ui.functional is True and not [u for u in ui.unmet if u.startswith("tool:")]
def test_hint_for_each_requirement_kind() -> None:
uv = tools_for("python-fastapi")[0]
assert "astral.sh" in R.hint_for(Requirement(kind="tool", ref="uv"), uv)
assert R.hint_for(Requirement(kind="system", ref="pandoc")) == (
"sudo apt install pandoc"
)
assert "api" in R.hint_for(Requirement(kind="deployment", ref="api"))

View File

@@ -46,6 +46,31 @@ class TestSupabaseStackResolution:
assert "install" in actions and "uninstall" in actions
class TestHugoStackResolution:
def test_hugo_provides_only_build_verbs(self) -> None:
"""Hugo is build-only: it resolves build/install/uninstall but NOT the
lint/test/type-check/check verbs it has no native tooling for."""
p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "hugo"})
actions = available_actions(p)
assert "build" in actions
assert "install" in actions and "uninstall" in actions
for absent in ("lint", "test", "type-check", "check"):
assert absent not in actions
def test_declared_command_still_overrides_hugo(self) -> None:
"""A hugo program can still declare a verb the stack doesn't provide —
declared commands are resolved regardless of the handler's `provides`."""
p = ProgramSpec.model_validate(
{
"source": "/tmp/x",
"stack": "hugo",
"commands": {"test": [["htmltest"]]},
}
)
actions = available_actions(p)
assert "test" in actions and "build" in actions
class TestResolution:
def test_stack_only_program_unchanged(self) -> None:
"""A program with a stack and no commands resolves all stack verbs."""
@@ -61,7 +86,11 @@ class TestResolution:
p = ProgramSpec.model_validate(
{
"source": "/tmp/y",
"commands": {"lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/y"]]},
"commands": {
"lint": [["make", "lint"]],
"test": [["make", "test"]],
"run": [["./bin/y"]],
},
}
)
actions = available_actions(p)
@@ -79,15 +108,24 @@ class TestResolution:
def test_hybrid_override_one_verb(self) -> None:
"""A stack program can override a single verb; the rest fall back to stack."""
p = ProgramSpec.model_validate(
{"source": "/tmp/z", "stack": "python-cli", "commands": {"test": [["pytest", "-x"]]}}
{
"source": "/tmp/z",
"stack": "python-cli",
"commands": {"test": [["pytest", "-x"]]},
}
)
assert _declared_commands(p, "test") == [["pytest", "-x"]]
assert _declared_commands(p, "build") is None # build still comes from the stack
assert (
_declared_commands(p, "build") is None
) # build still comes from the stack
def test_build_declared_via_buildspec(self) -> None:
"""`build` is declared through BuildSpec.commands, not CommandsSpec."""
p = ProgramSpec.model_validate(
{"source": "/tmp/w", "build": {"commands": [["make"]], "outputs": ["dist/"]}}
{
"source": "/tmp/w",
"build": {"commands": [["make"]], "outputs": ["dist/"]},
}
)
assert _declared_commands(p, "build") == [["make"]]
assert "build" in available_actions(p)

View File

@@ -0,0 +1,185 @@
"""Tests for castle_core.tool_schema — deriving neutral tool-call cores from --help."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from castle_core.tool_schema import (
ToolSchemaError,
_command_core,
_extract_subcommands,
_sanitize_name,
_structured_core,
collect_tool_help,
derive_tool_schema,
is_tool_schema_core,
render_tool_schema,
tool_executable,
validate_tool_schema_core,
)
FLAT_HELP = """\
usage: widget [-h] [--deep] [--mode {a,b}] target
Do a widget thing.
positional arguments:
target What to widget
options:
-h, --help show this help message and exit
--deep Recurse
--mode {a,b} Pick a mode
"""
SUBCMD_HELP = "positional arguments:\n {build,deploy}\n build Build\n"
def _fake_config(programs: dict) -> object:
return SimpleNamespace(programs=programs)
class TestPure:
def test_sanitize_name(self) -> None:
assert _sanitize_name("my.tool v2") == "my_tool_v2"
assert len(_sanitize_name("x" * 100)) == 64
def test_flat_tool_has_no_subcommands(self) -> None:
assert _extract_subcommands(FLAT_HELP) == []
def test_argparse_choice_row_subcommands(self) -> None:
assert _extract_subcommands(SUBCMD_HELP) == ["build", "deploy"]
def test_structured_core_typed_params(self) -> None:
core = _structured_core("widget", FLAT_HELP)
assert core is not None
props = core["parameters"]["properties"]
assert set(props) == {"target", "deep", "mode"}
assert props["deep"]["type"] == "boolean"
assert props["mode"]["enum"] == ["a", "b"]
assert core["parameters"]["required"] == ["target"]
def test_structured_none_without_options(self) -> None:
assert _structured_core("x", "free-form help\n") is None
def test_command_core_shape(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "Usage: jq ...", deep=False)
assert core["parameters"]["required"] == ["command"]
class TestRender:
def test_render_openai_default(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
out = render_tool_schema(core)
assert out["type"] == "function"
assert out["function"]["parameters"] == core["parameters"]
def test_render_anthropic(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
out = render_tool_schema(core, "anthropic")
assert set(out) == {"name", "description", "input_schema"}
assert out["input_schema"] == core["parameters"]
def test_render_neutral_is_identity(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
assert render_tool_schema(core, "neutral") is core
class TestResolution:
def test_tool_executable_falls_back_to_name(self) -> None:
cfg = _fake_config({"widget": SimpleNamespace(source=None)})
assert tool_executable(cfg, "widget") == "widget"
def test_tool_executable_reads_pyproject_scripts(self, tmp_path) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project.scripts]\nintent-router = "x:main"\n'
)
cfg = _fake_config({"r": SimpleNamespace(source=str(tmp_path))})
assert tool_executable(cfg, "r") == "intent-router"
class TestDerive:
def test_missing_executable_raises(self) -> None:
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
with pytest.raises(ToolSchemaError, match="not on PATH"):
derive_tool_schema(cfg, "nope")
def test_derive_real_tool_returns_neutral_core(self) -> None:
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
core = derive_tool_schema(cfg, "python3")
assert core["name"] == "python3"
assert "parameters" in core # neutral shape, not input_schema
assert core["description"]
class TestLLMAssistHelpers:
"""Deterministic helpers that feed / validate the (castle-api) LLM assist."""
def test_collect_tool_help_real_tool(self) -> None:
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
help_text = collect_tool_help(cfg, "python3")
assert help_text and "$ python3 --help" in help_text
def test_collect_tool_help_missing_raises(self) -> None:
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
with pytest.raises(ToolSchemaError, match="not on PATH"):
collect_tool_help(cfg, "nope")
def test_is_tool_schema_core(self) -> None:
good = {
"name": "jq",
"description": "process json",
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
}
assert is_tool_schema_core(good) is True
assert is_tool_schema_core({"name": "x", "description": "y"}) is False # no params
assert is_tool_schema_core("nope") is False
class TestValidate:
"""Deterministic validation — shape + JSON-Schema meta-validation."""
_GOOD = {
"name": "jq",
"description": "process json",
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
}
def test_valid_returns_no_errors(self) -> None:
assert validate_tool_schema_core(self._GOOD) == []
def test_malformed_enum_is_caught(self) -> None:
"""The qwen defect: `enum` as a count, not a list — a JSON-Schema error."""
bad = {
"name": "search",
"description": "d",
"parameters": {
"type": "object",
"properties": {"sub": {"type": "string", "enum": 4}},
},
}
errors = validate_tool_schema_core(bad)
assert errors and any("JSON Schema" in e for e in errors)
def test_missing_parameters(self) -> None:
assert validate_tool_schema_core({"name": "x", "description": "y"})
def test_empty_properties(self) -> None:
errors = validate_tool_schema_core(
{"name": "x", "description": "y", "parameters": {"type": "object", "properties": {}}}
)
assert any("empty" in e for e in errors)
def test_bad_name_char(self) -> None:
errors = validate_tool_schema_core(
{**self._GOOD, "name": "has space"}
)
assert any("name" in e for e in errors)
def test_parameters_not_object_type(self) -> None:
errors = validate_tool_schema_core(
{"name": "x", "description": "y", "parameters": {"type": "array", "properties": {}}}
)
assert errors

View File

@@ -80,3 +80,43 @@ def test_public_static_frontend_gets_ingress() -> None:
hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]
if "hostname" in r}
assert hosts == {"guestbook.pub.payne.io"}
def test_public_host_override_used_as_ingress_hostname() -> None:
# An apex `public_host` overrides <sub>.<public_domain> for the public name,
# but the origin still bridges to the internal <sub>.<gateway_domain> host.
reg = _registry(deployed={
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
static_root="/data/repos/payne-io/public", public=True,
public_host="payne.io"),
})
cfg = yaml.safe_load(generate_tunnel_config(reg))
rules = {r["hostname"]: r for r in cfg["ingress"] if "hostname" in r}
assert set(rules) == {"payne.io"}
assert rules["payne.io"]["originRequest"]["httpHostHeader"] == "payne-io.civil.payne.io"
assert public_hostnames(reg) == ["payne.io"]
def test_public_host_default_and_override_coexist() -> None:
reg = _registry(deployed={
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
subdomain="app", public=True),
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
static_root="/d/public", public=True, public_host="payne.io"),
})
assert set(public_hostnames(reg)) == {"app.pub.payne.io", "payne.io"}
def test_public_host_works_without_default_public_domain() -> None:
# A deployment with its own public_host publishes even if the node has no
# default public_domain; the plain public service (no override) is skipped.
reg = _registry(public_domain=None, deployed={
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
subdomain="app", public=True),
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
static_root="/d/public", public=True, public_host="payne.io"),
})
assert public_hostnames(reg) == ["payne.io"]
cfg = yaml.safe_load(generate_tunnel_config(reg))
hosts = {r["hostname"] for r in cfg["ingress"] if "hostname" in r}
assert hosts == {"payne.io"}

View File

@@ -370,6 +370,26 @@ to the backend root, so root-relative asset URLs and `window.location`-derived
WebSocket URLs just work (the failure mode of the old prefix-stripping `handle_path`
routes is gone). Caddy proxies WebSocket upgrades transparently.
#### `public_host` — Publish on a different domain / apex (opt-in)
`gateway.public_domain` is the **default** public zone. To project a specific
deployment on a *different* domain, or at an **apex** (`payne.io`, which can't be a
`<name>.<zone>` subdomain), set an exact `public_host` FQDN on the deployment (only
valid with `reach: public` / `public: true`):
```yaml
reach: public
public_host: payne.io # exact hostname; overrides <name>.<public_domain>
```
The tunnel origin still bridges to the internal `<name>.<gateway.domain>` host, and
the gateway also serves the custom host **LAN-direct** with its own DNS-01 cert. The
public CNAME is reconciled into whichever accessible Cloudflare zone is the host's
longest suffix, so the `CLOUDFLARE_PUBLIC_DNS_TOKEN` (and the gateway's
`CLOUDFLARE_API_TOKEN`, for the cert) must have `DNS:Edit` on that zone. A
deployment with `public_host` publishes even with no node-wide `public_domain`. Full
prerequisites (tokens + LAN DNS for the apex): @docs/tunnel-setup.md.
**Gateway routes — one concept, three target kinds.** The gateway maps a public
**address** (always a subdomain host, `<name>.<domain>`) to a **target**:

96
docs/stacks/hugo.md Normal file
View File

@@ -0,0 +1,96 @@
# Hugo static sites in Castle
> **This is a stack — creation-time guidance for writing _new_ sites.**
> A stack is a template + conventions, not a runtime requirement. `castle program
> create --stack hugo` scaffolds from it (via Hugo's own `hugo new site`) and seeds
> the program's default build verb. An existing Hugo site adopted with `castle
> program add` doesn't need this stack — it declares its own `commands:` /
> `build:`. See @docs/registry.md for `commands:`, `stack:` (optional), and `repo:`.
How to build, serve, and manage [Hugo](https://gohugo.io) sites as castle programs.
## Stack
- Generator: Hugo (extended recommended — needed for SCSS/asset processing)
- Build: `hugo --gc --minify``public/`
- Served: `manager: caddy` static deployment, in place at `<name>.<gateway.domain>`
- Package manager (only if a theme needs an asset pipeline): pnpm
Hugo has one meaningful dev verb — **build**. It has no native lint/test/type-check,
so the stack advertises only `build` / `install` / `uninstall`; `castle check` and
friends aren't offered (a site can still declare its own, e.g. an HTML linter, under
`commands:` — a declared verb always wins over the stack).
## Create a new site
```bash
castle program create my-site --stack hugo --description "My site"
cd /data/repos/my-site
castle program build my-site # hugo --gc --minify -> public/
castle apply my-site # serve at my-site.<gateway.domain>
```
The scaffold delegates the canonical skeleton to `hugo new site` (archetypes/,
content/, layouts/, static/, themes/, hugo.toml) and overlays the pieces a bare
skeleton lacks: minimal `layouts/` so the site **builds and serves without a
theme**, an example `content/posts/hello.md`, a castle-flavored `hugo.toml`
(`baseURL = "/"`, so assets resolve at the root of the site's own subdomain), and a
`.gitignore` for the regenerated `public/` and `resources/`.
Develop with the live server:
```bash
hugo server -D # http://localhost:1313, rebuilds on save
```
## Adding a theme
Drop a theme under `themes/` (usually a git submodule) and set `theme` in
`hugo.toml`:
```bash
git submodule add https://github.com/<owner>/<theme>.git themes/<theme>
```
Themes with an **asset pipeline** (e.g. Blowfish + Tailwind) need a pre-build step
before `hugo`. Declare it as a two-step `build` in `programs/<name>.yaml` — a
declared `build.commands` overrides the stack's single-step default:
```yaml
build:
commands:
- [pnpm, build] # compile the theme's CSS/JS
- [hugo, --gc, --minify] # render the site -> public/
outputs: [public]
```
One-time setup those themes expect (run once in the source tree):
```bash
git submodule update --init --recursive
cd themes/<theme> && pnpm install
```
## Deployment shape
`castle program create --stack hugo` writes:
- **`programs/<name>.yaml`** — `source`, `stack: hugo`, `build.outputs: [public]`.
- **`deployments/statics/<name>.yaml`** — `manager: caddy`, `root: public`,
`reach: internal` (flip to `public` to also expose over the tunnel).
The gateway serves `<source>/public` in place — no copy, no Node/Hugo process at
runtime. `castle program build` regenerates `public/`; `castle apply` renders the
route and reloads the gateway.
## Adopting an existing Hugo site
No stack needed — adopt the repo and declare how it builds:
```bash
castle program add /path/to/site --name my-site
```
Then set `build.commands` (as above) and add a `manager: caddy` deployment. This is
how a site with a bespoke build (submodule theme + Tailwind) is wired without the
scaffold. See @docs/registry.md.

View File

@@ -129,6 +129,49 @@ Without the token, `castle apply` instead prints the exact command to run per ho
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
```
### Publishing on a different domain (`public_host`)
By default a public deployment is projected at `<name>.<gateway.public_domain>`
`public_domain` is the *default* public zone. To publish a specific deployment on a
**different** domain, or at an **apex** (e.g. `payne.io`, which can't be expressed
as `<name>.<zone>`), set an exact `public_host` on the deployment:
```yaml
# deployments/statics/payne-io.yaml
manager: caddy
program: payne-io
root: public
reach: public
public_host: payne.io # exact FQDN — overrides <name>.<public_domain>
```
`public_host` is an exact hostname (no scheme/port/path) and only applies with
`reach: public`. When set:
- **Tunnel ingress** maps that hostname to the tunnel; the origin still bridges to
the deployment's *internal* host (`<name>.<gateway.domain>`), so Caddy routes it
and the internal wildcard cert validates — same as the default path.
- **Public DNS** reconcile routes the CNAME into whichever accessible Cloudflare
zone is the longest suffix of the host (so `payne.io` lands in zone `payne.io`,
`x.other.org` in `other.org`). It reconciles across **every** zone the
`CLOUDFLARE_PUBLIC_DNS_TOKEN` can see, so the token must have `DNS:Edit` on each
target zone. Cloudflare flattens the apex CNAME automatically.
- **LAN-direct HTTPS.** The gateway also serves the custom host directly as its own
Caddy site, obtaining that host's cert via DNS-01 (the global `acme_dns`). This
requires the gateway's `CLOUDFLARE_API_TOKEN` to have `DNS:Edit` on the host's
zone too, and LAN DNS to resolve the host to this node — for an apex add e.g.
`address=/payne.io/<node-ip>` on the LAN resolver (the `*.<gateway.domain>`
wildcard doesn't cover a foreign apex).
A deployment with `public_host` publishes even if no node-wide `public_domain` is
configured. Prerequisites in one place, for `payne-io``https://payne.io`:
| need | why |
|------|-----|
| `CLOUDFLARE_PUBLIC_DNS_TOKEN` has `DNS:Edit` on `payne.io` | the proxied apex CNAME → tunnel |
| `CLOUDFLARE_API_TOKEN` (gateway) has `DNS:Edit` on `payne.io` | the LAN-direct apex cert (DNS-01) |
| LAN DNS `address=/payne.io/<node-ip>` | LAN browsers resolve the apex to the gateway |
## The part that isn't the tunnel
Reachability is the easy half. Anything public also needs, per service:

132
uv.lock generated
View File

@@ -40,6 +40,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "castle-api"
version = "0.1.0"
@@ -119,6 +128,7 @@ name = "castle-core"
version = "0.1.0"
source = { editable = "core" }
dependencies = [
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pyyaml" },
]
@@ -131,6 +141,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "jsonschema", specifier = ">=4.0.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
]
@@ -297,6 +308,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -562,6 +600,100 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
[[package]]
name = "ruff"
version = "0.15.2"