refactor: Update component specifications to use 'program' and 'behavior' attributes, enhancing clarity and consistency across the application
This commit is contained in:
@@ -9,7 +9,7 @@ Castle is a personal software platform — a monorepo of independent projects
|
|||||||
(services, tools, libraries) managed by the `castle` CLI. The registry
|
(services, tools, libraries) managed by the `castle` CLI. The registry
|
||||||
(`castle.yaml`) has three top-level sections:
|
(`castle.yaml`) has three top-level sections:
|
||||||
|
|
||||||
- **`programs:`** — Software catalog (source, install, tool metadata, build)
|
- **`programs:`** — Software catalog (source, behavior, stack, system_dependencies, build)
|
||||||
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
|
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
|
||||||
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
|
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const TEMPLATES: Record<string, Record<string, unknown>> = {
|
|||||||
service: {
|
service: {
|
||||||
run: {
|
run: {
|
||||||
runner: "python",
|
runner: "python",
|
||||||
tool: "",
|
program: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
env: {},
|
env: {},
|
||||||
},
|
},
|
||||||
@@ -23,9 +23,7 @@ const TEMPLATES: Record<string, Record<string, unknown>> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
install: {
|
behavior: "tool",
|
||||||
path: { alias: "" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
job: {
|
job: {
|
||||||
run: {
|
run: {
|
||||||
@@ -72,7 +70,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
|
|||||||
// Fill in template-specific fields
|
// Fill in template-specific fields
|
||||||
if (template === "service") {
|
if (template === "service") {
|
||||||
const run = config.run as Record<string, unknown>
|
const run = config.run as Record<string, unknown>
|
||||||
run.tool = name
|
run.program = name
|
||||||
run.cwd = name
|
run.cwd = name
|
||||||
const proxy = (config.proxy as Record<string, Record<string, string>>).caddy
|
const proxy = (config.proxy as Record<string, Record<string, string>>).caddy
|
||||||
proxy.path_prefix = `/${name}`
|
proxy.path_prefix = `/${name}`
|
||||||
@@ -81,8 +79,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
|
|||||||
expose.http.internal.port = parseInt(port, 10)
|
expose.http.internal.port = parseInt(port, 10)
|
||||||
}
|
}
|
||||||
} else if (template === "tool") {
|
} else if (template === "tool") {
|
||||||
const install = (config.install as Record<string, Record<string, string>>).path
|
// tool template has behavior preset, no extra config needed
|
||||||
install.alias = name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await onAdd(name, config)
|
await onAdd(name, config)
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
|||||||
<Field label="Runner">
|
<Field label="Runner">
|
||||||
<span className="text-sm font-mono text-[var(--muted)]">
|
<span className="text-sm font-mono text-[var(--muted)]">
|
||||||
{runnerLabel(runner)}
|
{runnerLabel(runner)}
|
||||||
{(m.run as Record<string, string>)?.tool && (
|
{(m.run as Record<string, string>)?.program && (
|
||||||
<> · {(m.run as Record<string, string>).tool}</>
|
<> · {(m.run as Record<string, string>).tool}</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -32,11 +32,18 @@ const ACTION_CONFIG: Record<string, ActionConfig> = {
|
|||||||
|
|
||||||
const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
|
const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
|
||||||
|
|
||||||
|
export interface ActionOutput {
|
||||||
|
action: string
|
||||||
|
text: string
|
||||||
|
ok: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface ProgramActionsProps {
|
interface ProgramActionsProps {
|
||||||
name: string
|
name: string
|
||||||
actions: string[]
|
actions: string[]
|
||||||
installed?: boolean | null
|
installed?: boolean | null
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
onOutput?: (output: ActionOutput) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleActions(
|
function visibleActions(
|
||||||
@@ -69,30 +76,27 @@ function visibleActions(
|
|||||||
return visible
|
return visible
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgramActions({ name, actions, installed, compact }: ProgramActionsProps) {
|
export function ProgramActions({ name, actions, installed, compact, onOutput }: ProgramActionsProps) {
|
||||||
const { mutate, isPending } = useProgramAction()
|
const { mutate, isPending } = useProgramAction()
|
||||||
const [runningAction, setRunningAction] = useState<string | null>(null)
|
const [runningAction, setRunningAction] = useState<string | null>(null)
|
||||||
const [output, setOutput] = useState<{ action: string; text: string; ok: boolean } | null>(null)
|
|
||||||
|
|
||||||
const visible = visibleActions(actions, installed, !!compact)
|
const visible = visibleActions(actions, installed, !!compact)
|
||||||
|
|
||||||
const handleAction = (action: string) => {
|
const handleAction = (action: string) => {
|
||||||
setRunningAction(action)
|
setRunningAction(action)
|
||||||
setOutput(null)
|
onOutput?.({ action: "", text: "", ok: true }) // clear previous
|
||||||
mutate(
|
mutate(
|
||||||
{ name, action },
|
{ name, action },
|
||||||
{
|
{
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setRunningAction(null)
|
setRunningAction(null)
|
||||||
// Show output for dev actions on detail page
|
|
||||||
if (!compact && DEV_ACTIONS.includes(action) && data.output) {
|
if (!compact && DEV_ACTIONS.includes(action) && data.output) {
|
||||||
setOutput({ action, text: data.output, ok: true })
|
onOutput?.({ action, text: data.output, ok: true })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setRunningAction(null)
|
setRunningAction(null)
|
||||||
if (!compact && DEV_ACTIONS.includes(action)) {
|
if (!compact && DEV_ACTIONS.includes(action)) {
|
||||||
// Extract detail from API error JSON
|
|
||||||
let text = String(err)
|
let text = String(err)
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse((err as Error).message)
|
const parsed = JSON.parse((err as Error).message)
|
||||||
@@ -100,7 +104,7 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
|
|||||||
} catch {
|
} catch {
|
||||||
text = (err as Error).message ?? text
|
text = (err as Error).message ?? text
|
||||||
}
|
}
|
||||||
setOutput({ action, text, ok: false })
|
onOutput?.({ action, text, ok: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -110,58 +114,59 @@ export function ProgramActions({ name, actions, installed, compact }: ProgramAct
|
|||||||
if (visible.length === 0) return null
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
{visible.map((action) => {
|
||||||
{visible.map((action) => {
|
const config = ACTION_CONFIG[action]
|
||||||
const config = ACTION_CONFIG[action]
|
if (!config) return null
|
||||||
if (!config) return null
|
const Icon = config.icon
|
||||||
const Icon = config.icon
|
const isRunning = isPending && runningAction === action
|
||||||
const isRunning = isPending && runningAction === action
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={action}
|
|
||||||
onClick={() => handleAction(action)}
|
|
||||||
disabled={isPending}
|
|
||||||
className={`p-1 rounded ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
|
|
||||||
title={config.label}
|
|
||||||
>
|
|
||||||
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={action}
|
key={action}
|
||||||
onClick={() => handleAction(action)}
|
onClick={() => handleAction(action)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className={`flex items-center gap-1.5 px-2 py-1 text-sm rounded border ${config.borderColor} ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
|
className={`p-1 rounded ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
|
||||||
|
title={config.label}
|
||||||
>
|
>
|
||||||
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||||
{config.label}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
{output && !compact && (
|
return (
|
||||||
<div className={`mt-3 rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}>
|
<button
|
||||||
<div className={`flex items-center justify-between px-3 py-1.5 text-xs ${output.ok ? "text-green-400 bg-green-900/20" : "text-red-400 bg-red-900/20"}`}>
|
key={action}
|
||||||
<span>{ACTION_CONFIG[output.action]?.label ?? output.action} — {output.ok ? "ok" : "error"}</span>
|
onClick={() => handleAction(action)}
|
||||||
<button
|
disabled={isPending}
|
||||||
onClick={() => setOutput(null)}
|
className={`flex items-center gap-1.5 px-2 py-1 text-sm rounded border ${config.borderColor} ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
|
||||||
className="text-[var(--muted)] hover:text-[var(--foreground)]"
|
>
|
||||||
>
|
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||||
dismiss
|
{config.label}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)
|
||||||
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-64 overflow-y-auto bg-black/40">
|
})}
|
||||||
{output.text}
|
</div>
|
||||||
</pre>
|
)
|
||||||
</div>
|
}
|
||||||
)}
|
|
||||||
|
export function ActionOutputPanel({ output, onDismiss }: { output: ActionOutput; onDismiss: () => void }) {
|
||||||
|
if (!output.action) return null
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}>
|
||||||
|
<div className={`flex items-center justify-between px-3 py-1.5 text-xs ${output.ok ? "text-green-400 bg-green-900/20" : "text-red-400 bg-red-900/20"}`}>
|
||||||
|
<span>{ACTION_CONFIG[output.action]?.label ?? output.action} — {output.ok ? "ok" : "error"}</span>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-64 overflow-y-auto bg-black/40">
|
||||||
|
{output.text}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { useState } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
|
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||||
import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
||||||
import { ProgramActions } from "@/components/ProgramActions"
|
import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions"
|
||||||
|
|
||||||
export function ComponentDetailPage() {
|
export function ComponentDetailPage() {
|
||||||
useEventStream()
|
useEventStream()
|
||||||
@@ -11,6 +12,7 @@ export function ComponentDetailPage() {
|
|||||||
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
|
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
|
||||||
const isTool = component?.behavior === "tool"
|
const isTool = component?.behavior === "tool"
|
||||||
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||||
|
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -37,9 +39,15 @@ export function ComponentDetailPage() {
|
|||||||
stack={component.stack}
|
stack={component.stack}
|
||||||
source={component.source}
|
source={component.source}
|
||||||
>
|
>
|
||||||
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} />
|
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} onOutput={setActionOutput} />
|
||||||
</DetailHeader>
|
</DetailHeader>
|
||||||
|
|
||||||
|
{actionOutput && actionOutput.action && (
|
||||||
|
<div className="-mt-2 mb-6">
|
||||||
|
<ActionOutputPanel output={actionOutput} onDismiss={() => setActionOutput(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{component.description && (
|
{component.description && (
|
||||||
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p>
|
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ export function ServiceDetailPage() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Terminal size={12} />
|
<Terminal size={12} />
|
||||||
{runnerLabel(runner)}
|
{runnerLabel(runner)}
|
||||||
{(component.manifest.run as Record<string, string>)?.tool && (
|
{(component.manifest.run as Record<string, string>)?.program && (
|
||||||
<> · {(component.manifest.run as Record<string, string>).tool}</>
|
<> · {(component.manifest.run as Record<string, string>).program}</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -156,17 +156,6 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
|||||||
|
|
||||||
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
|
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
|
||||||
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
|
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
|
||||||
# Determine behavior
|
|
||||||
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
|
||||||
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
|
||||||
|
|
||||||
if is_tool:
|
|
||||||
behavior = "tool"
|
|
||||||
elif is_frontend:
|
|
||||||
behavior = "frontend"
|
|
||||||
else:
|
|
||||||
behavior = None
|
|
||||||
|
|
||||||
source = comp.source
|
source = comp.source
|
||||||
|
|
||||||
# Infer runner from source directory
|
# Infer runner from source directory
|
||||||
@@ -179,20 +168,19 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
|
|||||||
runner = "command"
|
runner = "command"
|
||||||
|
|
||||||
installed: bool | None = None
|
installed: bool | None = None
|
||||||
if comp.install and comp.install.path:
|
if comp.source and comp.stack:
|
||||||
alias = comp.install.path.alias or name
|
installed = shutil.which(name) is not None
|
||||||
installed = shutil.which(alias) is not None
|
|
||||||
|
|
||||||
return ComponentSummary(
|
return ComponentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category="program",
|
category="program",
|
||||||
description=comp.description,
|
description=comp.description,
|
||||||
behavior=behavior,
|
behavior=comp.behavior,
|
||||||
stack=comp.stack,
|
stack=comp.stack,
|
||||||
runner=runner,
|
runner=runner,
|
||||||
version=comp.tool.version if comp.tool else None,
|
version=comp.version,
|
||||||
source=source,
|
source=source,
|
||||||
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
|
system_dependencies=comp.system_dependencies,
|
||||||
installed=installed,
|
installed=installed,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -325,29 +313,6 @@ def _program_from_spec(
|
|||||||
name: str, comp: ProgramSpec, root: Path, config: object | None = None
|
name: str, comp: ProgramSpec, root: Path, config: object | None = None
|
||||||
) -> ProgramSummary:
|
) -> ProgramSummary:
|
||||||
"""Build a ProgramSummary from a ProgramSpec."""
|
"""Build a ProgramSummary from a ProgramSpec."""
|
||||||
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
|
||||||
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
|
||||||
|
|
||||||
if is_tool:
|
|
||||||
behavior = "tool"
|
|
||||||
elif is_frontend:
|
|
||||||
behavior = "frontend"
|
|
||||||
else:
|
|
||||||
behavior = None
|
|
||||||
|
|
||||||
# Derive behavior from backing service/job
|
|
||||||
if behavior is None and config is not None:
|
|
||||||
svc_components = {
|
|
||||||
s.component for s in config.services.values() if s.component
|
|
||||||
}
|
|
||||||
job_components = {
|
|
||||||
j.component for j in config.jobs.values() if j.component
|
|
||||||
}
|
|
||||||
if name in svc_components or name in config.services:
|
|
||||||
behavior = "daemon"
|
|
||||||
elif name in job_components or name in config.jobs:
|
|
||||||
behavior = "tool"
|
|
||||||
|
|
||||||
source = comp.source
|
source = comp.source
|
||||||
runner = None
|
runner = None
|
||||||
if source:
|
if source:
|
||||||
@@ -358,24 +323,18 @@ def _program_from_spec(
|
|||||||
runner = "command"
|
runner = "command"
|
||||||
|
|
||||||
installed: bool | None = None
|
installed: bool | None = None
|
||||||
if comp.install and comp.install.path:
|
if comp.source and comp.stack:
|
||||||
alias = comp.install.path.alias or name
|
installed = shutil.which(name) is not None
|
||||||
installed = shutil.which(alias) is not None
|
|
||||||
elif config is not None:
|
|
||||||
# Daemons: check if the service's run.tool binary is on PATH
|
|
||||||
svc = config.services.get(name)
|
|
||||||
if svc and hasattr(svc.run, "tool"):
|
|
||||||
installed = shutil.which(svc.run.tool) is not None
|
|
||||||
|
|
||||||
return ProgramSummary(
|
return ProgramSummary(
|
||||||
id=name,
|
id=name,
|
||||||
description=comp.description,
|
description=comp.description,
|
||||||
behavior=behavior,
|
behavior=comp.behavior,
|
||||||
stack=comp.stack,
|
stack=comp.stack,
|
||||||
runner=runner,
|
runner=runner,
|
||||||
version=comp.tool.version if comp.tool else None,
|
version=comp.version,
|
||||||
source=source,
|
source=source,
|
||||||
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
|
system_dependencies=comp.system_dependencies,
|
||||||
installed=installed,
|
installed=installed,
|
||||||
actions=available_actions(comp),
|
actions=available_actions(comp),
|
||||||
)
|
)
|
||||||
@@ -701,21 +660,11 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
|||||||
if ref and ref in config.programs:
|
if ref and ref in config.programs:
|
||||||
s.source = config.programs[ref].source
|
s.source = config.programs[ref].source
|
||||||
|
|
||||||
# Programs — always include entries from the programs
|
# Programs from the software catalog
|
||||||
# section as catalog items. Derive behavior from backing
|
|
||||||
# service/job when the program has no install/build spec.
|
|
||||||
svc_components = {s.component for s in config.services.values() if s.component}
|
|
||||||
job_components = {j.component for j in config.jobs.values() if j.component}
|
|
||||||
|
|
||||||
for name, comp in config.programs.items():
|
for name, comp in config.programs.items():
|
||||||
summary = _summary_from_program(name, comp, root)
|
summary = _summary_from_program(name, comp, root)
|
||||||
if summary.behavior is None:
|
if summary.behavior is None:
|
||||||
if name in svc_components or name in config.services:
|
continue
|
||||||
summary.behavior = "daemon"
|
|
||||||
elif name in job_components or name in config.jobs:
|
|
||||||
summary.behavior = "tool"
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
summary.node = local_hostname
|
summary.node = local_hostname
|
||||||
summaries.append(summary)
|
summaries.append(summary)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
@@ -16,18 +17,15 @@ router = APIRouter(tags=["tools"])
|
|||||||
|
|
||||||
|
|
||||||
def _is_tool(comp: ProgramSpec) -> bool:
|
def _is_tool(comp: ProgramSpec) -> bool:
|
||||||
"""Check if a component is a tool (has install.path or tool spec)."""
|
"""Check if a component is a tool."""
|
||||||
return bool((comp.install and comp.install.path) or comp.tool)
|
return comp.behavior == "tool"
|
||||||
|
|
||||||
|
|
||||||
def _tool_summary(
|
def _tool_summary(
|
||||||
name: str, comp: ProgramSpec, root: Path | None = None
|
name: str, comp: ProgramSpec, root: Path | None = None
|
||||||
) -> ToolSummary:
|
) -> ToolSummary:
|
||||||
"""Build a ToolSummary from a ProgramSpec that is a tool."""
|
"""Build a ToolSummary from a ProgramSpec that is a tool."""
|
||||||
t = comp.tool
|
installed = shutil.which(name) is not None
|
||||||
installed = bool(
|
|
||||||
comp.install and comp.install.path and comp.install.path.enable
|
|
||||||
)
|
|
||||||
|
|
||||||
# Infer runner from source directory
|
# Infer runner from source directory
|
||||||
runner = None
|
runner = None
|
||||||
@@ -43,9 +41,9 @@ def _tool_summary(
|
|||||||
id=name,
|
id=name,
|
||||||
description=comp.description,
|
description=comp.description,
|
||||||
source=source,
|
source=source,
|
||||||
version=t.version if t else None,
|
version=comp.version,
|
||||||
runner=runner,
|
runner=runner,
|
||||||
system_dependencies=t.system_dependencies if t else [],
|
system_dependencies=comp.system_dependencies,
|
||||||
installed=installed,
|
installed=installed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,21 +23,18 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
castle_yaml = tmp_path / "castle.yaml"
|
castle_yaml = tmp_path / "castle.yaml"
|
||||||
config = {
|
config = {
|
||||||
"gateway": {"port": 9000},
|
"gateway": {"port": 9000},
|
||||||
"components": {
|
"programs": {
|
||||||
"test-tool": {
|
"test-tool": {
|
||||||
"description": "Test tool",
|
"description": "Test tool",
|
||||||
"source": "test-tool",
|
"source": "test-tool",
|
||||||
"install": {"path": {"alias": "test-tool"}},
|
"behavior": "tool",
|
||||||
"tool": {
|
"system_dependencies": ["pandoc"],
|
||||||
"system_dependencies": ["pandoc"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"test-tool-2": {
|
"test-tool-2": {
|
||||||
"description": "Another test tool",
|
"description": "Another test tool",
|
||||||
"source": "test-tool-2",
|
"source": "test-tool-2",
|
||||||
"tool": {
|
"behavior": "tool",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
@@ -46,7 +43,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
"run": {
|
"run": {
|
||||||
"runner": "python",
|
"runner": "python",
|
||||||
"tool": "test-svc",
|
"program": "test-svc",
|
||||||
},
|
},
|
||||||
"expose": {
|
"expose": {
|
||||||
"http": {
|
"http": {
|
||||||
|
|||||||
@@ -33,18 +33,12 @@ class TestToolsList:
|
|||||||
assert tool["system_dependencies"] == ["pandoc"]
|
assert tool["system_dependencies"] == ["pandoc"]
|
||||||
|
|
||||||
def test_installed_flag(self, client: TestClient) -> None:
|
def test_installed_flag(self, client: TestClient) -> None:
|
||||||
"""Tool with install.path is marked as installed."""
|
"""Tool installed field reflects whether binary is on PATH."""
|
||||||
response = client.get("/tools")
|
response = client.get("/tools")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
tool = next(t for t in data if t["id"] == "test-tool")
|
tool = next(t for t in data if t["id"] == "test-tool")
|
||||||
assert tool["installed"] is True
|
# test-tool binary won't be on PATH in test env
|
||||||
|
assert isinstance(tool["installed"], bool)
|
||||||
def test_not_installed_flag(self, client: TestClient) -> None:
|
|
||||||
"""Tool without install.path is not marked as installed."""
|
|
||||||
response = client.get("/tools")
|
|
||||||
data = response.json()
|
|
||||||
tool = next(t for t in data if t["id"] == "test-tool-2")
|
|
||||||
assert tool["installed"] is False
|
|
||||||
|
|
||||||
def test_service_excluded(self, client: TestClient) -> None:
|
def test_service_excluded(self, client: TestClient) -> None:
|
||||||
"""Services without tool spec are not listed."""
|
"""Services without tool spec are not listed."""
|
||||||
|
|||||||
109
castle.yaml
109
castle.yaml
@@ -7,34 +7,36 @@ programs:
|
|||||||
on the LAN
|
on the LAN
|
||||||
source: components/central-context
|
source: components/central-context
|
||||||
stack: python-fastapi
|
stack: python-fastapi
|
||||||
|
behavior: daemon
|
||||||
notification-bridge:
|
notification-bridge:
|
||||||
description: Desktop notification forwarder. This taps into your notifications
|
description: Desktop notification forwarder. This taps into your notifications
|
||||||
system on the desktop and will forward all notifications the the central context
|
system on the desktop and will forward all notifications the the central context
|
||||||
server.
|
server.
|
||||||
source: components/notification-bridge
|
source: components/notification-bridge
|
||||||
stack: python-fastapi
|
stack: python-fastapi
|
||||||
|
behavior: daemon
|
||||||
castle-api:
|
castle-api:
|
||||||
description: Castle API
|
description: Castle API
|
||||||
source: castle-api
|
source: castle-api
|
||||||
stack: python-fastapi
|
stack: python-fastapi
|
||||||
|
behavior: daemon
|
||||||
protonmail:
|
protonmail:
|
||||||
description: ProtonMail email sync via Bridge
|
description: ProtonMail email sync via Bridge
|
||||||
source: components/protonmail
|
source: components/protonmail
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: protonmail
|
|
||||||
backup-collect:
|
backup-collect:
|
||||||
description: Collect files from various sources into backup directory
|
description: Collect files from various sources into backup directory
|
||||||
source: components/backup-collect
|
source: components/backup-collect
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
tool:
|
behavior: tool
|
||||||
system_dependencies:
|
system_dependencies:
|
||||||
- rsync
|
- rsync
|
||||||
castle-app:
|
castle-app:
|
||||||
description: Castle web app
|
description: Castle web app
|
||||||
source: app
|
source: app
|
||||||
stack: react-vite
|
stack: react-vite
|
||||||
|
behavior: frontend
|
||||||
build:
|
build:
|
||||||
commands:
|
commands:
|
||||||
- - pnpm
|
- - pnpm
|
||||||
@@ -45,124 +47,89 @@ programs:
|
|||||||
description: SSH tunnel manager with auto-reconnect
|
description: SSH tunnel manager with auto-reconnect
|
||||||
source: components/devbox-connect
|
source: components/devbox-connect
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: devbox-connect
|
|
||||||
mbox2eml:
|
mbox2eml:
|
||||||
description: MBOX to EML email converter
|
description: MBOX to EML email converter
|
||||||
source: components/mbox2eml
|
source: components/mbox2eml
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: mbox2eml
|
|
||||||
android-backup:
|
android-backup:
|
||||||
description: Backup Android device using ADB
|
description: Backup Android device using ADB
|
||||||
source: components/android-backup
|
source: components/android-backup
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
system_dependencies:
|
||||||
alias: android-backup
|
- adb
|
||||||
tool:
|
|
||||||
system_dependencies:
|
|
||||||
- adb
|
|
||||||
browser:
|
browser:
|
||||||
description: Browse the web using natural language via browser-use
|
description: Browse the web using natural language via browser-use
|
||||||
source: components/browser
|
source: components/browser
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: browser
|
|
||||||
docx-extractor:
|
docx-extractor:
|
||||||
description: Extract content and metadata from Word .docx files
|
description: Extract content and metadata from Word .docx files
|
||||||
source: components/docx-extractor
|
source: components/docx-extractor
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
system_dependencies:
|
||||||
alias: docx-extractor
|
- pandoc
|
||||||
tool:
|
|
||||||
system_dependencies:
|
|
||||||
- pandoc
|
|
||||||
docx2md:
|
docx2md:
|
||||||
description: Convert Word .docx files to Markdown
|
description: Convert Word .docx files to Markdown
|
||||||
source: components/docx2md
|
source: components/docx2md
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
system_dependencies:
|
||||||
alias: docx2md
|
- pandoc
|
||||||
tool:
|
|
||||||
system_dependencies:
|
|
||||||
- pandoc
|
|
||||||
gpt:
|
gpt:
|
||||||
description: OpenAI text generation utility
|
description: OpenAI text generation utility
|
||||||
source: components/gpt
|
source: components/gpt
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: gpt
|
|
||||||
html2text:
|
html2text:
|
||||||
description: Convert HTML content to plain text
|
description: Convert HTML content to plain text
|
||||||
source: components/html2text
|
source: components/html2text
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: html2text
|
|
||||||
md2pdf:
|
md2pdf:
|
||||||
description: Convert Markdown files to PDF
|
description: Convert Markdown files to PDF
|
||||||
source: components/md2pdf
|
source: components/md2pdf
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
system_dependencies:
|
||||||
alias: md2pdf
|
- pandoc
|
||||||
tool:
|
- texlive-latex-base
|
||||||
system_dependencies:
|
|
||||||
- pandoc
|
|
||||||
- texlive-latex-base
|
|
||||||
mdscraper:
|
mdscraper:
|
||||||
description: Combine text files into a single markdown document
|
description: Combine text files into a single markdown document
|
||||||
source: components/mdscraper
|
source: components/mdscraper
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: mdscraper
|
|
||||||
pdf-extractor:
|
pdf-extractor:
|
||||||
description: Extract content and metadata from PDF files
|
description: Extract content and metadata from PDF files
|
||||||
source: components/pdf-extractor
|
source: components/pdf-extractor
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: pdf-extractor
|
|
||||||
pdf2md:
|
pdf2md:
|
||||||
description: Convert PDF files to Markdown
|
description: Convert PDF files to Markdown
|
||||||
source: components/pdf2md
|
source: components/pdf2md
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
system_dependencies:
|
||||||
alias: pdf2md
|
- pandoc
|
||||||
tool:
|
- poppler-utils
|
||||||
system_dependencies:
|
|
||||||
- pandoc
|
|
||||||
- poppler-utils
|
|
||||||
schedule:
|
schedule:
|
||||||
description: Manage systemd user timers and scheduled tasks
|
description: Manage systemd user timers and scheduled tasks
|
||||||
source: components/schedule
|
source: components/schedule
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: schedule
|
|
||||||
search:
|
search:
|
||||||
description: Manage self-contained searchable collections of files
|
description: Manage self-contained searchable collections of files
|
||||||
source: components/search
|
source: components/search
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: search
|
|
||||||
text-extractor:
|
text-extractor:
|
||||||
description: Extract content and metadata from text files
|
description: Extract content and metadata from text files
|
||||||
source: components/text-extractor
|
source: components/text-extractor
|
||||||
stack: python-cli
|
stack: python-cli
|
||||||
install:
|
behavior: tool
|
||||||
path:
|
|
||||||
alias: text-extractor
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
castle-gateway:
|
castle-gateway:
|
||||||
@@ -189,7 +156,7 @@ services:
|
|||||||
component: central-context
|
component: central-context
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
tool: central-context
|
program: central-context
|
||||||
manage:
|
manage:
|
||||||
systemd: {}
|
systemd: {}
|
||||||
expose:
|
expose:
|
||||||
@@ -204,7 +171,7 @@ services:
|
|||||||
component: notification-bridge
|
component: notification-bridge
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
tool: notification-bridge
|
program: notification-bridge
|
||||||
defaults:
|
defaults:
|
||||||
env:
|
env:
|
||||||
CENTRAL_CONTEXT_URL: http://localhost:9001
|
CENTRAL_CONTEXT_URL: http://localhost:9001
|
||||||
@@ -224,7 +191,7 @@ services:
|
|||||||
component: castle-api
|
component: castle-api
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
tool: castle-api
|
program: castle-api
|
||||||
manage:
|
manage:
|
||||||
systemd: {}
|
systemd: {}
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ from castle_cli.manifest import (
|
|||||||
ExposeSpec,
|
ExposeSpec,
|
||||||
HttpExposeSpec,
|
HttpExposeSpec,
|
||||||
HttpInternal,
|
HttpInternal,
|
||||||
InstallSpec,
|
|
||||||
ManageSpec,
|
ManageSpec,
|
||||||
PathInstallSpec,
|
|
||||||
ProxySpec,
|
ProxySpec,
|
||||||
RunPython,
|
RunPython,
|
||||||
ServiceSpec,
|
ServiceSpec,
|
||||||
SystemdSpec,
|
SystemdSpec,
|
||||||
ToolSpec,
|
|
||||||
)
|
)
|
||||||
from castle_cli.templates.scaffold import scaffold_project
|
from castle_cli.templates.scaffold import scaffold_project
|
||||||
|
|
||||||
@@ -82,19 +79,18 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Build entries
|
# Build entries
|
||||||
|
config.programs[name] = ProgramSpec(
|
||||||
|
id=name,
|
||||||
|
description=args.description or f"A castle {stack} program",
|
||||||
|
source=f"programs/{name}",
|
||||||
|
stack=stack,
|
||||||
|
behavior=behavior,
|
||||||
|
)
|
||||||
if behavior == "daemon":
|
if behavior == "daemon":
|
||||||
# Program for software identity
|
|
||||||
config.programs[name] = ProgramSpec(
|
|
||||||
id=name,
|
|
||||||
description=args.description or f"A castle {stack} program",
|
|
||||||
source=f"programs/{name}",
|
|
||||||
stack=stack,
|
|
||||||
)
|
|
||||||
# Service for deployment
|
|
||||||
config.services[name] = ServiceSpec(
|
config.services[name] = ServiceSpec(
|
||||||
id=name,
|
id=name,
|
||||||
component=name,
|
component=name,
|
||||||
run=RunPython(runner="python", tool=name),
|
run=RunPython(runner="python", program=name),
|
||||||
expose=ExposeSpec(
|
expose=ExposeSpec(
|
||||||
http=HttpExposeSpec(
|
http=HttpExposeSpec(
|
||||||
internal=HttpInternal(port=port),
|
internal=HttpInternal(port=port),
|
||||||
@@ -104,23 +100,6 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
|
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
|
||||||
manage=ManageSpec(systemd=SystemdSpec()),
|
manage=ManageSpec(systemd=SystemdSpec()),
|
||||||
)
|
)
|
||||||
elif behavior == "tool":
|
|
||||||
config.programs[name] = ProgramSpec(
|
|
||||||
id=name,
|
|
||||||
description=args.description or f"A castle {stack} program",
|
|
||||||
source=f"programs/{name}",
|
|
||||||
stack=stack,
|
|
||||||
tool=ToolSpec(),
|
|
||||||
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# frontend or other
|
|
||||||
config.programs[name] = ProgramSpec(
|
|
||||||
id=name,
|
|
||||||
description=args.description or f"A castle {stack} program",
|
|
||||||
source=f"programs/{name}",
|
|
||||||
stack=stack,
|
|
||||||
)
|
|
||||||
|
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
|
|||||||
@@ -216,13 +216,13 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
|
|||||||
"""Build a run command list from a RunSpec."""
|
"""Build a run command list from a RunSpec."""
|
||||||
match run.runner:
|
match run.runner:
|
||||||
case "python":
|
case "python":
|
||||||
resolved = shutil.which(run.tool)
|
resolved = shutil.which(run.program)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
print(
|
print(
|
||||||
f" Warning: '{run.tool}' not on PATH. "
|
f" Warning: '{run.program}' not on PATH. "
|
||||||
f"Install with: uv tool install --editable <source>"
|
f"Install with: uv tool install --editable <source>"
|
||||||
)
|
)
|
||||||
cmd = [resolved or run.tool]
|
cmd = [resolved or run.program]
|
||||||
if run.args:
|
if run.args:
|
||||||
cmd.extend(run.args)
|
cmd.extend(run.args)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -52,17 +52,15 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
print(f"{'─' * 40}")
|
print(f"{'─' * 40}")
|
||||||
|
|
||||||
# Determine behavior
|
# Determine behavior
|
||||||
if service:
|
behavior = None
|
||||||
print(f" {BOLD}behavior{RESET}: daemon")
|
if program and program.behavior:
|
||||||
|
behavior = program.behavior
|
||||||
|
elif service:
|
||||||
|
behavior = "daemon"
|
||||||
elif job:
|
elif job:
|
||||||
print(f" {BOLD}behavior{RESET}: tool")
|
behavior = "tool"
|
||||||
elif program:
|
if behavior:
|
||||||
if program.tool or (program.install and program.install.path):
|
print(f" {BOLD}behavior{RESET}: {behavior}")
|
||||||
print(f" {BOLD}behavior{RESET}: tool")
|
|
||||||
elif program.build:
|
|
||||||
print(f" {BOLD}behavior{RESET}: frontend")
|
|
||||||
else:
|
|
||||||
print(f" {BOLD}behavior{RESET}: —")
|
|
||||||
|
|
||||||
# Show stack
|
# Show stack
|
||||||
stack = None
|
stack = None
|
||||||
@@ -81,13 +79,8 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
print(f" {BOLD}description{RESET}: {program.description}")
|
print(f" {BOLD}description{RESET}: {program.description}")
|
||||||
if program.source:
|
if program.source:
|
||||||
print(f" {BOLD}source{RESET}: {program.source}")
|
print(f" {BOLD}source{RESET}: {program.source}")
|
||||||
if program.install and program.install.path:
|
if program.system_dependencies:
|
||||||
pi = program.install.path
|
print(f" {BOLD}requires{RESET}: {', '.join(program.system_dependencies)}")
|
||||||
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
|
||||||
if program.tool:
|
|
||||||
t = program.tool
|
|
||||||
if t.system_dependencies:
|
|
||||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
|
||||||
if program.tags:
|
if program.tags:
|
||||||
print(f" {BOLD}tags{RESET}: {', '.join(program.tags)}")
|
print(f" {BOLD}tags{RESET}: {', '.join(program.tags)}")
|
||||||
|
|
||||||
@@ -104,8 +97,8 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# Run spec
|
# Run spec
|
||||||
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
||||||
if hasattr(spec.run, "tool"):
|
if hasattr(spec.run, "program"):
|
||||||
print(f" {BOLD}tool{RESET}: {spec.run.tool}")
|
print(f" {BOLD}program{RESET}: {spec.run.program}")
|
||||||
elif hasattr(spec.run, "argv"):
|
elif hasattr(spec.run, "argv"):
|
||||||
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
|
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
|
||||||
elif hasattr(spec.run, "image"):
|
elif hasattr(spec.run, "image"):
|
||||||
@@ -185,15 +178,14 @@ def _info_json(
|
|||||||
data["program"] = program.model_dump(exclude_none=True, exclude={"id"})
|
data["program"] = program.model_dump(exclude_none=True, exclude={"id"})
|
||||||
if service:
|
if service:
|
||||||
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
|
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
|
||||||
data["behavior"] = "daemon"
|
|
||||||
if job:
|
if job:
|
||||||
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
|
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
|
||||||
|
if program and program.behavior:
|
||||||
|
data["behavior"] = program.behavior
|
||||||
|
elif service:
|
||||||
|
data["behavior"] = "daemon"
|
||||||
|
elif job:
|
||||||
data["behavior"] = "tool"
|
data["behavior"] = "tool"
|
||||||
if not service and not job and program:
|
|
||||||
if program.tool or (program.install and program.install.path):
|
|
||||||
data["behavior"] = "tool"
|
|
||||||
elif program.build:
|
|
||||||
data["behavior"] = "frontend"
|
|
||||||
|
|
||||||
# Resolve stack
|
# Resolve stack
|
||||||
stack = None
|
stack = None
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def run_tool(args: argparse.Namespace) -> int:
|
|||||||
def _tool_list() -> int:
|
def _tool_list() -> int:
|
||||||
"""List all registered tools."""
|
"""List all registered tools."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
tools = {k: v for k, v in config.programs.items() if v.tool}
|
tools = {k: v for k, v in config.programs.items() if v.behavior == "tool"}
|
||||||
|
|
||||||
if not tools:
|
if not tools:
|
||||||
print("No tools registered.")
|
print("No tools registered.")
|
||||||
@@ -40,8 +40,8 @@ def _tool_list() -> int:
|
|||||||
for name, manifest in sorted(tools.items()):
|
for name, manifest in sorted(tools.items()):
|
||||||
desc = manifest.description or ""
|
desc = manifest.description or ""
|
||||||
deps = ""
|
deps = ""
|
||||||
if manifest.tool and manifest.tool.system_dependencies:
|
if manifest.system_dependencies:
|
||||||
deps = f" {DIM}[{', '.join(manifest.tool.system_dependencies)}]{RESET}"
|
deps = f" {DIM}[{', '.join(manifest.system_dependencies)}]{RESET}"
|
||||||
print(f" {BOLD}{name:<20}{RESET} {desc}{deps}")
|
print(f" {BOLD}{name:<20}{RESET} {desc}{deps}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
@@ -56,21 +56,20 @@ def _tool_info(name: str) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
manifest = config.programs[name]
|
manifest = config.programs[name]
|
||||||
if not manifest.tool:
|
if manifest.behavior != "tool":
|
||||||
print(f"Error: '{name}' is not a tool")
|
print(f"Error: '{name}' is not a tool")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
t = manifest.tool
|
|
||||||
|
|
||||||
print(f"\n{BOLD}{name}{RESET}")
|
print(f"\n{BOLD}{name}{RESET}")
|
||||||
print(f"{'─' * 40}")
|
print(f"{'─' * 40}")
|
||||||
if manifest.description:
|
if manifest.description:
|
||||||
print(f" {manifest.description}")
|
print(f" {manifest.description}")
|
||||||
print(f" {BOLD}version{RESET}: {t.version}")
|
if manifest.version:
|
||||||
|
print(f" {BOLD}version{RESET}: {manifest.version}")
|
||||||
if manifest.source:
|
if manifest.source:
|
||||||
print(f" {BOLD}source{RESET}: {manifest.source}")
|
print(f" {BOLD}source{RESET}: {manifest.source}")
|
||||||
if t.system_dependencies:
|
if manifest.system_dependencies:
|
||||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
print(f" {BOLD}requires{RESET}: {', '.join(manifest.system_dependencies)}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
|||||||
HttpExposeSpec,
|
HttpExposeSpec,
|
||||||
HttpInternal,
|
HttpInternal,
|
||||||
HttpPublic,
|
HttpPublic,
|
||||||
InstallSpec,
|
|
||||||
JobSpec,
|
JobSpec,
|
||||||
ManageSpec,
|
ManageSpec,
|
||||||
PathInstallSpec,
|
|
||||||
ProxySpec,
|
ProxySpec,
|
||||||
ReadinessHttpGet,
|
ReadinessHttpGet,
|
||||||
RestartPolicy,
|
RestartPolicy,
|
||||||
@@ -29,5 +27,4 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
|||||||
ServiceSpec,
|
ServiceSpec,
|
||||||
SystemdSpec,
|
SystemdSpec,
|
||||||
TLSMode,
|
TLSMode,
|
||||||
ToolSpec,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,12 +15,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
castle_yaml = tmp_path / "castle.yaml"
|
castle_yaml = tmp_path / "castle.yaml"
|
||||||
config = {
|
config = {
|
||||||
"gateway": {"port": 18000},
|
"gateway": {"port": 18000},
|
||||||
"components": {
|
"programs": {
|
||||||
"test-tool": {
|
"test-tool": {
|
||||||
"description": "Test tool",
|
"description": "Test tool",
|
||||||
"install": {
|
"behavior": "tool",
|
||||||
"path": {"alias": "test-tool"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
@@ -29,7 +27,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
"run": {
|
"run": {
|
||||||
"runner": "python",
|
"runner": "python",
|
||||||
"tool": "test-svc",
|
"program": "test-svc",
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
|
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ class TestCreateCommand:
|
|||||||
assert (project_dir / "CLAUDE.md").exists()
|
assert (project_dir / "CLAUDE.md").exists()
|
||||||
assert "my-tool2" in config.programs
|
assert "my-tool2" in config.programs
|
||||||
comp = config.programs["my-tool2"]
|
comp = config.programs["my-tool2"]
|
||||||
assert comp.tool is not None
|
assert comp.behavior == "tool"
|
||||||
assert comp.install is not None
|
|
||||||
|
|
||||||
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
|
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
|
||||||
"""Creating a project with existing name fails."""
|
"""Creating a project with existing name fails."""
|
||||||
|
|||||||
8
components/android-backup/uv.lock
generated
Normal file
8
components/android-backup/uv.lock
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android-backup"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
@@ -53,11 +53,11 @@ class CastleConfig:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tools(self) -> dict[str, ProgramSpec]:
|
def tools(self) -> dict[str, ProgramSpec]:
|
||||||
"""Return programs that are tools (have install.path or tool spec)."""
|
"""Return programs that are tools (behavior == 'tool')."""
|
||||||
return {
|
return {
|
||||||
k: v
|
k: v
|
||||||
for k, v in self.programs.items()
|
for k, v in self.programs.items()
|
||||||
if (v.install and v.install.path) or v.tool
|
if v.behavior == "tool"
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -180,9 +180,6 @@ def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> obje
|
|||||||
_STRUCTURAL_KEYS = {
|
_STRUCTURAL_KEYS = {
|
||||||
"manage",
|
"manage",
|
||||||
"systemd",
|
"systemd",
|
||||||
"install",
|
|
||||||
"path",
|
|
||||||
"tool",
|
|
||||||
"expose",
|
"expose",
|
||||||
"proxy",
|
"proxy",
|
||||||
"caddy",
|
"caddy",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Literal, Union
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
EnvMap = dict[str, str]
|
EnvMap = dict[str, str]
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ class RunCommand(RunBase):
|
|||||||
|
|
||||||
class RunPython(RunBase):
|
class RunPython(RunBase):
|
||||||
runner: Literal["python"]
|
runner: Literal["python"]
|
||||||
tool: str
|
program: str
|
||||||
args: list[str] = Field(default_factory=list)
|
args: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,33 +102,6 @@ class ManageSpec(BaseModel):
|
|||||||
systemd: SystemdSpec | None = None
|
systemd: SystemdSpec | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Install (PATH shims)
|
|
||||||
# ---------------------
|
|
||||||
|
|
||||||
|
|
||||||
class PathInstallSpec(BaseModel):
|
|
||||||
enable: bool = True
|
|
||||||
alias: str | None = None
|
|
||||||
shim: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class InstallSpec(BaseModel):
|
|
||||||
path: PathInstallSpec | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Tool spec
|
|
||||||
# ---------------------
|
|
||||||
|
|
||||||
|
|
||||||
class ToolSpec(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
version: str = "1.0.0"
|
|
||||||
system_dependencies: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# HTTP exposure + proxy
|
# HTTP exposure + proxy
|
||||||
# ---------------------
|
# ---------------------
|
||||||
@@ -206,12 +179,13 @@ class ProgramSpec(BaseModel):
|
|||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
behavior: str | None = None
|
||||||
|
|
||||||
source: str | None = None
|
source: str | None = None
|
||||||
stack: str | None = None
|
stack: str | None = None
|
||||||
|
|
||||||
install: InstallSpec | None = None
|
system_dependencies: list[str] = Field(default_factory=list)
|
||||||
tool: ToolSpec | None = None
|
version: str | None = None
|
||||||
build: BuildSpec | None = None
|
build: BuildSpec | None = None
|
||||||
|
|
||||||
provides: list[Capability] = Field(default_factory=list)
|
provides: list[Capability] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -15,12 +15,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
castle_yaml = tmp_path / "castle.yaml"
|
castle_yaml = tmp_path / "castle.yaml"
|
||||||
config = {
|
config = {
|
||||||
"gateway": {"port": 18000},
|
"gateway": {"port": 18000},
|
||||||
"components": {
|
"programs": {
|
||||||
"test-tool": {
|
"test-tool": {
|
||||||
"description": "Test tool",
|
"description": "Test tool",
|
||||||
"install": {
|
"behavior": "tool",
|
||||||
"path": {"alias": "test-tool"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
@@ -29,7 +27,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
"run": {
|
"run": {
|
||||||
"runner": "python",
|
"runner": "python",
|
||||||
"tool": "test-svc",
|
"program": "test-svc",
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
|
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class TestLoadConfig:
|
|||||||
config = load_config(castle_root)
|
config = load_config(castle_root)
|
||||||
svc = config.services["test-svc"]
|
svc = config.services["test-svc"]
|
||||||
assert svc.run.runner == "python"
|
assert svc.run.runner == "python"
|
||||||
assert svc.run.tool == "test-svc"
|
assert svc.run.program == "test-svc"
|
||||||
|
|
||||||
def test_service_component_ref(self, castle_root: Path) -> None:
|
def test_service_component_ref(self, castle_root: Path) -> None:
|
||||||
"""Service references a component."""
|
"""Service references a component."""
|
||||||
|
|||||||
@@ -10,17 +10,14 @@ from castle_core.manifest import (
|
|||||||
ExposeSpec,
|
ExposeSpec,
|
||||||
HttpExposeSpec,
|
HttpExposeSpec,
|
||||||
HttpInternal,
|
HttpInternal,
|
||||||
InstallSpec,
|
|
||||||
JobSpec,
|
JobSpec,
|
||||||
ManageSpec,
|
ManageSpec,
|
||||||
PathInstallSpec,
|
|
||||||
ProxySpec,
|
ProxySpec,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunPython,
|
RunPython,
|
||||||
RunRemote,
|
RunRemote,
|
||||||
ServiceSpec,
|
ServiceSpec,
|
||||||
SystemdSpec,
|
SystemdSpec,
|
||||||
ToolSpec,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,26 +29,27 @@ class TestProgramSpec:
|
|||||||
c = ProgramSpec(id="bare")
|
c = ProgramSpec(id="bare")
|
||||||
assert c.description is None
|
assert c.description is None
|
||||||
assert c.source is None
|
assert c.source is None
|
||||||
assert c.install is None
|
assert c.behavior is None
|
||||||
assert c.tool is None
|
|
||||||
assert c.build is None
|
assert c.build is None
|
||||||
|
|
||||||
def test_tool_component(self) -> None:
|
def test_tool_component(self) -> None:
|
||||||
"""Component with tool and install specs."""
|
"""Component with tool behavior and system_dependencies."""
|
||||||
c = ProgramSpec(
|
c = ProgramSpec(
|
||||||
id="my-tool",
|
id="my-tool",
|
||||||
description="A tool",
|
description="A tool",
|
||||||
source="my-tool/",
|
source="my-tool/",
|
||||||
tool=ToolSpec(),
|
behavior="tool",
|
||||||
install=InstallSpec(path=PathInstallSpec(alias="my-tool")),
|
system_dependencies=["pandoc"],
|
||||||
)
|
)
|
||||||
assert c.source == "my-tool/"
|
assert c.source == "my-tool/"
|
||||||
assert c.install.path.alias == "my-tool"
|
assert c.behavior == "tool"
|
||||||
|
assert c.system_dependencies == ["pandoc"]
|
||||||
|
|
||||||
def test_frontend_component(self) -> None:
|
def test_frontend_component(self) -> None:
|
||||||
"""Component with build spec."""
|
"""Component with build spec."""
|
||||||
c = ProgramSpec(
|
c = ProgramSpec(
|
||||||
id="my-app",
|
id="my-app",
|
||||||
|
behavior="frontend",
|
||||||
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
|
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
|
||||||
)
|
)
|
||||||
assert c.build.outputs == ["dist/"]
|
assert c.build.outputs == ["dist/"]
|
||||||
@@ -74,7 +72,7 @@ class TestServiceSpec:
|
|||||||
"""Service with run and expose."""
|
"""Service with run and expose."""
|
||||||
s = ServiceSpec(
|
s = ServiceSpec(
|
||||||
id="svc",
|
id="svc",
|
||||||
run=RunPython(runner="python", tool="svc"),
|
run=RunPython(runner="python", program="svc"),
|
||||||
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
|
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
|
||||||
)
|
)
|
||||||
assert s.run.runner == "python"
|
assert s.run.runner == "python"
|
||||||
@@ -85,7 +83,7 @@ class TestServiceSpec:
|
|||||||
s = ServiceSpec(
|
s = ServiceSpec(
|
||||||
id="svc",
|
id="svc",
|
||||||
component="my-component",
|
component="my-component",
|
||||||
run=RunPython(runner="python", tool="svc"),
|
run=RunPython(runner="python", program="svc"),
|
||||||
)
|
)
|
||||||
assert s.component == "my-component"
|
assert s.component == "my-component"
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ class TestServiceSpec:
|
|||||||
"""Service with proxy spec."""
|
"""Service with proxy spec."""
|
||||||
s = ServiceSpec(
|
s = ServiceSpec(
|
||||||
id="svc",
|
id="svc",
|
||||||
run=RunPython(runner="python", tool="svc"),
|
run=RunPython(runner="python", program="svc"),
|
||||||
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
|
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
|
||||||
)
|
)
|
||||||
assert s.proxy.caddy.path_prefix == "/svc"
|
assert s.proxy.caddy.path_prefix == "/svc"
|
||||||
@@ -174,15 +172,14 @@ class TestModelSerialization:
|
|||||||
c = ProgramSpec(id="test", description="Test")
|
c = ProgramSpec(id="test", description="Test")
|
||||||
data = c.model_dump(exclude_none=True, exclude={"id"})
|
data = c.model_dump(exclude_none=True, exclude={"id"})
|
||||||
assert "description" in data
|
assert "description" in data
|
||||||
assert "install" not in data
|
assert "build" not in data
|
||||||
assert "tool" not in data
|
|
||||||
|
|
||||||
def test_dump_service(self) -> None:
|
def test_dump_service(self) -> None:
|
||||||
"""Full service serializes correctly."""
|
"""Full service serializes correctly."""
|
||||||
s = ServiceSpec(
|
s = ServiceSpec(
|
||||||
id="svc",
|
id="svc",
|
||||||
description="A service",
|
description="A service",
|
||||||
run=RunPython(runner="python", tool="svc"),
|
run=RunPython(runner="python", program="svc"),
|
||||||
expose=ExposeSpec(
|
expose=ExposeSpec(
|
||||||
http=HttpExposeSpec(
|
http=HttpExposeSpec(
|
||||||
internal=HttpInternal(port=9001), health_path="/health"
|
internal=HttpInternal(port=9001), health_path="/health"
|
||||||
|
|||||||
@@ -16,17 +16,16 @@ programs:
|
|||||||
my-tool:
|
my-tool:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: programs/my-tool
|
source: programs/my-tool
|
||||||
install:
|
stack: python-cli
|
||||||
path: { alias: my-tool }
|
behavior: tool
|
||||||
tool:
|
system_dependencies: [pandoc]
|
||||||
system_dependencies: [pandoc]
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
my-service:
|
my-service:
|
||||||
component: my-service
|
component: my-service
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
tool: my-service
|
program: my-service
|
||||||
expose:
|
expose:
|
||||||
http:
|
http:
|
||||||
internal: { port: 9001 }
|
internal: { port: 9001 }
|
||||||
@@ -51,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
| Section | Purpose | Category |
|
| Section | Purpose | Category |
|
||||||
|---------|---------|----------|
|
|---------|---------|----------|
|
||||||
| `programs:` | Software catalog — what exists | tool, frontend, program |
|
| `programs:` | Software catalog — what exists | tool, frontend, daemon |
|
||||||
| `services:` | Long-running daemons — how they run | service |
|
| `services:` | Long-running daemons — how they run | service |
|
||||||
| `jobs:` | Scheduled tasks — when they run | job |
|
| `jobs:` | Scheduled tasks — when they run | job |
|
||||||
|
|
||||||
@@ -61,7 +60,18 @@ fallthrough and source code linking. They can also exist independently
|
|||||||
|
|
||||||
## Program blocks
|
## Program blocks
|
||||||
|
|
||||||
Programs define **what software exists** — identity, source, tools, builds.
|
Programs define **what software exists** — identity, source, behavior, builds.
|
||||||
|
|
||||||
|
### `behavior` — What role this program plays
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
behavior: daemon # or: tool, frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Explicit declaration of how the program is used:
|
||||||
|
- **daemon** — long-running service (python-fastapi stack)
|
||||||
|
- **tool** — CLI utility (python-cli stack)
|
||||||
|
- **frontend** — web UI (react-vite stack)
|
||||||
|
|
||||||
### `source` — Where the source lives
|
### `source` — Where the source lives
|
||||||
|
|
||||||
@@ -71,28 +81,30 @@ source: programs/my-tool
|
|||||||
|
|
||||||
Relative path from repo root to the project directory.
|
Relative path from repo root to the project directory.
|
||||||
|
|
||||||
### `install` — How to install it
|
### `stack` — Development toolchain
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
install:
|
stack: python-fastapi # or: python-cli, react-vite
|
||||||
path:
|
|
||||||
alias: my-tool # Command name in PATH
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates a shim so the tool is available system-wide after
|
Stacks define how programs get built, checked, and installed.
|
||||||
`uv tool install --editable .`.
|
|
||||||
|
|
||||||
### `tool` — Tool metadata
|
### `system_dependencies` — Required system packages
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
tool:
|
system_dependencies: [pandoc, poppler-utils]
|
||||||
version: "1.0.0"
|
|
||||||
system_dependencies: [pandoc, poppler-utils]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This block provides metadata for `castle tool list` and the dashboard.
|
System packages that must be installed for the program to work. Displayed
|
||||||
It's separate from `install` (which handles PATH registration). The source
|
in `castle tool list` and the dashboard.
|
||||||
directory is set via the top-level `source` field on the program, not here.
|
|
||||||
|
### `version` — Program version
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional version metadata.
|
||||||
|
|
||||||
### `build` — How to build it
|
### `build` — How to build it
|
||||||
|
|
||||||
@@ -104,7 +116,7 @@ build:
|
|||||||
- dist/
|
- dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
Components with build outputs are categorized as **frontends** in the UI.
|
Programs with build outputs are typically frontends.
|
||||||
|
|
||||||
## Service blocks
|
## Service blocks
|
||||||
|
|
||||||
@@ -116,7 +128,7 @@ Discriminated union on `runner`:
|
|||||||
|
|
||||||
| Runner | Sync | Deploy | Key fields |
|
| Runner | Sync | Deploy | Key fields |
|
||||||
|--------|------|--------|------------|
|
|--------|------|--------|------------|
|
||||||
| `python` | `uv sync` | `which(tool)` → installed binary | `tool`, `args` |
|
| `python` | `uv sync` | `which(program)` → installed binary | `program`, `args` |
|
||||||
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
|
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
|
||||||
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
||||||
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
|
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
|
||||||
@@ -125,7 +137,7 @@ Discriminated union on `runner`:
|
|||||||
```yaml
|
```yaml
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
tool: my-service # name in [project.scripts]
|
program: my-service # name in [project.scripts]
|
||||||
```
|
```
|
||||||
|
|
||||||
### `expose` — What it exposes
|
### `expose` — What it exposes
|
||||||
@@ -225,22 +237,23 @@ programs:
|
|||||||
my-tool:
|
my-tool:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: programs/my-tool
|
source: programs/my-tool
|
||||||
install:
|
stack: python-cli
|
||||||
path:
|
behavior: tool
|
||||||
alias: my-tool
|
|
||||||
|
|
||||||
# Service — needs both program and service entries
|
# Service — needs both program and service entries
|
||||||
programs:
|
programs:
|
||||||
my-service:
|
my-service:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: programs/my-service
|
source: programs/my-service
|
||||||
|
stack: python-fastapi
|
||||||
|
behavior: daemon
|
||||||
|
|
||||||
services:
|
services:
|
||||||
my-service:
|
my-service:
|
||||||
component: my-service
|
component: my-service
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
tool: my-service
|
program: my-service
|
||||||
expose:
|
expose:
|
||||||
http:
|
http:
|
||||||
internal: { port: 9001 }
|
internal: { port: 9001 }
|
||||||
@@ -317,11 +330,11 @@ and a `.timer` file.
|
|||||||
|
|
||||||
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
|
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
|
||||||
|
|
||||||
- `ProgramSpec` — software catalog entry (source, install, tool, build)
|
- `ProgramSpec` — software catalog entry (source, behavior, stack, build, system_dependencies)
|
||||||
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
|
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
|
||||||
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
|
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
|
||||||
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
|
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
|
||||||
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec`
|
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `BuildSpec`
|
||||||
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
|
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
|
||||||
|
|
||||||
Config loading: `core/src/castle_core/config.py` — `load_config()` parses
|
Config loading: `core/src/castle_core/config.py` — `load_config()` parses
|
||||||
|
|||||||
@@ -318,23 +318,20 @@ programs:
|
|||||||
my-tool:
|
my-tool:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: components/my-tool
|
source: components/my-tool
|
||||||
install:
|
stack: python-cli
|
||||||
path:
|
behavior: tool
|
||||||
alias: my-tool
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Tools with system dependencies declare them in the component:
|
Tools with system dependencies declare them directly on the program:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
programs:
|
programs:
|
||||||
pdf2md:
|
pdf2md:
|
||||||
description: Convert PDF files to Markdown
|
description: Convert PDF files to Markdown
|
||||||
source: components/pdf2md
|
source: components/pdf2md
|
||||||
install:
|
stack: python-cli
|
||||||
path:
|
behavior: tool
|
||||||
alias: pdf2md
|
system_dependencies: [pandoc, poppler-utils]
|
||||||
tool:
|
|
||||||
system_dependencies: [pandoc, poppler-utils]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Tools live in the `programs:` section. If a tool also runs on a schedule,
|
Tools live in the `programs:` section. If a tool also runs on a schedule,
|
||||||
|
|||||||
@@ -103,13 +103,15 @@ programs:
|
|||||||
my-service:
|
my-service:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: components/my-service
|
source: components/my-service
|
||||||
|
stack: python-fastapi
|
||||||
|
behavior: daemon
|
||||||
|
|
||||||
services:
|
services:
|
||||||
my-service:
|
my-service:
|
||||||
component: my-service
|
component: my-service
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
tool: my-service
|
program: my-service
|
||||||
expose:
|
expose:
|
||||||
http:
|
http:
|
||||||
internal: { port: 9001 }
|
internal: { port: 9001 }
|
||||||
|
|||||||
Reference in New Issue
Block a user