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 { useState } from "react"
import type { ProgramDetail } from "@/types" import type { ProgramDetail } from "@/types"
import { useStacks } from "@/services/api/hooks"
import { Field, TextField, FormFooter } from "./fields" import { Field, TextField, FormFooter } from "./fields"
const SELECT = 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. */ /** Edit a program's catalog config (its source identity), not how it's deployed. */
export function ProgramFields({ program, onSave, onDelete }: Props) { export function ProgramFields({ program, onSave, onDelete }: Props) {
const m = program.manifest const m = program.manifest
const { data: stacks = [] } = useStacks()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = 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}`}> <select value={stack} onChange={(e) => setStack(e.target.value)} className={`w-48 ${SELECT}`}>
<option value="">(none)</option> <option value="">(none)</option>
<option value="python-cli">python-cli</option> {/* Options come from the backend (GET /stacks); include the current
<option value="python-fastapi">python-fastapi</option> value even if unknown so it never silently blanks. */}
<option value="react-vite">react-vite</option> {Array.from(new Set([...stacks, ...(stack ? [stack] : [])])).map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select> </select>
</Field> </Field>
<TextField label="Version" value={version} onChange={setVersion} width="w-32" hint="Optional metadata." /> <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-fastapi": "Python / FastAPI",
"python-cli": "Python / CLI", "python-cli": "Python / CLI",
"react-vite": "React / Vite", "react-vite": "React / Vite",
supabase: "Supabase",
rust: "Rust", rust: "Rust",
go: "Go", go: "Go",
bash: "Bash", 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) { export function useJob(name: string) {
return useQuery({ return useQuery({
queryKey: ["jobs", name], queryKey: ["jobs", name],

View File

@@ -4,12 +4,19 @@ from __future__ import annotations
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from castle_core.stacks import available_actions, run_action from castle_core.stacks import available_actions, available_stacks, run_action
from castle_api.config import get_config from castle_api.config import get_config
programs_router = APIRouter(tags=["programs"]) programs_router = APIRouter(tags=["programs"])
@programs_router.get("/stacks")
def list_stacks() -> list[str]:
"""Stack names castle has handlers for — populates the dashboard's stack select
and keeps it in sync with the backend (no hardcoded frontend list)."""
return available_stacks()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unified program action endpoint # Unified program action endpoint
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -12,6 +12,8 @@ from __future__ import annotations
import argparse import argparse
import sys import sys
from castle_core.stacks import available_stacks
from castle_cli import __version__ from castle_cli import __version__
DEV_VERBS = ["build", "test", "lint", "format", "type-check", "check"] DEV_VERBS = ["build", "test", "lint", "format", "type-check", "check"]
@@ -37,11 +39,7 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
p = sub.add_parser("create", help="Scaffold a new program") p = sub.add_parser("create", help="Scaffold a new program")
_add_name(p, "Program name") _add_name(p, "Program name")
p.add_argument( p.add_argument("--stack", choices=available_stacks(), default=None)
"--stack",
choices=["python-cli", "python-fastapi", "react-vite", "supabase"],
default=None,
)
p.add_argument("--description", default="", help="Program description") p.add_argument("--description", default="", help="Program description")
p.add_argument("--port", type=int, help="Port (daemons only)") p.add_argument("--port", type=int, help="Port (daemons only)")

View File

@@ -520,6 +520,13 @@ def get_handler(stack: str | None) -> StackHandler | None:
return HANDLERS.get(stack) return HANDLERS.get(stack)
def available_stacks() -> list[str]:
"""The stack names castle has handlers for — the single source of truth for the
CLI ``--stack`` choices, the ``GET /stacks`` endpoint, and the dashboard select.
"""
return sorted(HANDLERS)
def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None: def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
"""Declared argv-lists for a verb, or None. """Declared argv-lists for a verb, or None.