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

@@ -385,6 +385,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"],