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

@@ -3,10 +3,19 @@
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core import git
from castle_core.adopt import (
AdoptError,
build_adopted_program,
is_git_url,
looks_like_program,
)
from castle_core.config import write_program_file
from castle_core.stacks import available_actions, available_stacks, run_action
from castle_api import stream
@@ -22,6 +31,103 @@ def list_stacks() -> list[str]:
return available_stacks()
# ---------------------------------------------------------------------------
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
# web equivalent of `castle program add <path|git-url>`. Programs live on the
# server's filesystem, so the picker browses the *server's* dirs (a browser's
# native file dialog only sees the client machine).
# ---------------------------------------------------------------------------
@programs_router.get("/fs/browse")
def browse_filesystem(path: str | None = None) -> dict:
"""List sub-directories of ``path`` (default: the repos dir) so the dashboard
can browse to a program on the server. Directories only; hidden dirs skipped.
Each entry is flagged when it looks adoptable (a project manifest or git repo).
"""
config = get_config()
base = Path(path).expanduser() if path else config.repos_dir
try:
base = base.resolve()
except OSError as e:
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
if not base.exists():
raise HTTPException(status_code=404, detail=f"Path does not exist: {base}")
if not base.is_dir():
raise HTTPException(status_code=400, detail=f"Not a directory: {base}")
try:
children = sorted(base.iterdir(), key=lambda p: p.name.lower())
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {base}")
entries: list[dict] = []
for child in children:
if child.name.startswith("."):
continue
# A single unreadable child (can't stat/traverse) shouldn't sink the whole
# listing — skip it rather than 403 the directory.
try:
if not child.is_dir():
continue
entries.append(
{
"name": child.name,
"path": str(child),
"is_program": looks_like_program(child),
"is_git": (child / ".git").exists(),
}
)
except OSError:
continue
parent = str(base.parent) if base.parent != base else None
return {
"path": str(base),
"parent": parent,
"repos_dir": str(config.repos_dir),
"entries": entries,
}
class AdoptRequest(BaseModel):
target: str # a local server path or a git URL
name: str | None = None
description: str = ""
@programs_router.post("/programs/adopt")
def adopt_program(request: AdoptRequest) -> dict:
"""Adopt an existing repo as a program (the web `castle program add`).
``target`` is a local server path or a git URL. Writes just the new program's
file; declaring a deployment (service/job/tool/static) stays a separate step.
"""
target = request.target.strip()
if not target:
raise HTTPException(status_code=422, detail="A path or git URL is required.")
config = get_config()
try:
adopted = build_adopted_program(
config, target, name=request.name, description=request.description
)
except AdoptError as e:
raise HTTPException(status_code=422, detail=str(e))
config.programs[adopted.name] = adopted.spec
write_program_file(config, adopted.name)
return {
"ok": True,
"program": adopted.name,
"source": adopted.source,
"stack": adopted.stack,
"repo": adopted.repo,
"commands": adopted.commands,
"is_git_url": is_git_url(target),
}
# ---------------------------------------------------------------------------
# Git sync — pull a program's source working copy up to date (pull only; no
# build/apply/restart — converge stays an explicit, separate step). Declared