Populate the program stack select from castle-api

The dashboard's stack dropdown hardcoded its options (python-cli/fastapi/
react-vite), so a supabase program showed "(none)" — the value had no matching
option. Make the backend the single source of truth instead:

- core: available_stacks() returns sorted(HANDLERS) — the stacks castle has
  handlers for.
- castle-api: GET /stacks exposes it.
- cli: --stack choices derive from available_stacks() (drops the duplicated list).
- app: ProgramFields fetches /stacks (useStacks) and renders options from it,
  always including the current value so it never silently blanks; StackBadge
  gets a Supabase label.

A new backend stack now appears everywhere without a frontend edit.
This commit is contained in:
2026-06-30 21:43:11 -07:00
parent bd9ea76d6a
commit 14145c9f15
6 changed files with 38 additions and 9 deletions

View File

@@ -1,5 +1,6 @@
import { useState } from "react"
import type { ProgramDetail } from "@/types"
import { useStacks } from "@/services/api/hooks"
import { Field, TextField, FormFooter } from "./fields"
const SELECT =
@@ -21,6 +22,7 @@ type Obj = Record<string, unknown>
/** Edit a program's catalog config (its source identity), not how it's deployed. */
export function ProgramFields({ program, onSave, onDelete }: Props) {
const m = program.manifest
const { data: stacks = [] } = useStacks()
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
@@ -114,9 +116,13 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
>
<select value={stack} onChange={(e) => setStack(e.target.value)} className={`w-48 ${SELECT}`}>
<option value="">(none)</option>
<option value="python-cli">python-cli</option>
<option value="python-fastapi">python-fastapi</option>
<option value="react-vite">react-vite</option>
{/* Options come from the backend (GET /stacks); include the current
value even if unknown so it never silently blanks. */}
{Array.from(new Set([...stacks, ...(stack ? [stack] : [])])).map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</Field>
<TextField label="Version" value={version} onChange={setVersion} width="w-32" hint="Optional metadata." />

View File

@@ -22,6 +22,7 @@ export const STACK_LABELS: Record<string, string> = {
"python-fastapi": "Python / FastAPI",
"python-cli": "Python / CLI",
"react-vite": "React / Vite",
supabase: "Supabase",
rust: "Rust",
go: "Go",
bash: "Bash",

View File

@@ -49,6 +49,16 @@ export function useJobs() {
})
}
// The stacks castle has handlers for — authoritative source for the program
// stack select, so a new backend stack appears without a frontend change.
export function useStacks() {
return useQuery({
queryKey: ["stacks"],
queryFn: () => apiClient.get<string[]>("/stacks"),
staleTime: Infinity,
})
}
export function useJob(name: string) {
return useQuery({
queryKey: ["jobs", name],