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.
This commit is contained in:
2026-07-07 20:51:59 -07:00
parent 9a8e16143b
commit a9f1e5b099
7 changed files with 585 additions and 110 deletions

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 ? (