diff --git a/app/src/components/AddProgramForm.tsx b/app/src/components/AddProgramForm.tsx new file mode 100644 index 0000000..8f3427a --- /dev/null +++ b/app/src/components/AddProgramForm.tsx @@ -0,0 +1,216 @@ +import { useState } from "react" +import { useNavigate } from "react-router-dom" +import { X, Folder, ArrowUp, Check, GitBranch } from "lucide-react" +import { useBrowse, useAdoptProgram } from "@/services/api/hooks" +import { TextField } from "./detail/fields" + +const IS_GIT_URL = /^(https?:\/\/|git@|ssh:\/\/)|\.git$/ + +/** Split the typed path into the directory to browse and the trailing partial + * segment to filter by — the autocomplete engine. Typing within a directory only + * changes the filter (client-side, no refetch); crossing a "/" browses the new dir. + * "/data/repos/wid" -> browse "/data/repos", filter "wid" + * "/data/repos/" -> browse "/data/repos", filter "" + * "wid" -> browse the default repos dir, filter "wid" */ +function splitPath(raw: string): { dir: string | null; filter: string } { + if (raw === "") return { dir: null, filter: "" } + if (raw.endsWith("/")) return { dir: raw.length > 1 ? raw.slice(0, -1) : "/", filter: "" } + const idx = raw.lastIndexOf("/") + if (idx === -1) return { dir: null, filter: raw } + if (idx === 0) return { dir: "/", filter: raw.slice(1) } + return { dir: raw.slice(0, idx), filter: raw.slice(idx + 1) } +} + +/** Adopt an existing repo as a program (the web `castle program add`). Programs + * live on the *server's* filesystem, so the picker browses the server's dirs: type + * a path (autocompletes as you go), navigate the listing, or paste a git URL. */ +export function AddProgramForm({ + existingNames, + onCancel, +}: { + existingNames: string[] + onCancel: () => void +}) { + const navigate = useNavigate() + const adopt = useAdoptProgram() + + // The path bar is the single source of truth — its value is the adopt target. + // Empty browses the server's repos dir (surfaced as the placeholder), so the + // listing is populated from the first render without seeding the field. + const [input, setInput] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [error, setError] = useState("") + + const target = input.trim() + const isGit = IS_GIT_URL.test(target) + const { dir, filter } = splitPath(target) + const { data: browse, isFetching, error: browseError } = useBrowse(dir, !isGit) + + const entries = (browse?.entries ?? []).filter((e) => + filter ? e.name.toLowerCase().startsWith(filter.toLowerCase()) : true, + ) + + const suggestedName = target + ? target.replace(/\/+$/, "").split("/").pop()!.replace(/\.git$/, "") + : "" + const effectiveName = name.trim() || suggestedName + const nameError = + name && !/^[a-z0-9][a-z0-9-]*$/.test(name) + ? "lowercase letters, numbers, hyphens" + : effectiveName && existingNames.includes(effectiveName) + ? "already exists" + : "" + + const submit = async () => { + if (!target || nameError) return + setError("") + try { + const res = await adopt.mutateAsync({ + target, + name: name.trim() || undefined, + description: description.trim() || undefined, + }) + navigate(`/programs/${res.program}`) + } catch (e: unknown) { + let msg = e instanceof Error ? e.message : String(e) + try { + msg = JSON.parse((e as Error).message).detail ?? msg + } catch { + /* keep msg */ + } + setError(msg) + } + } + + return ( +
+
+

Add program

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Editable path bar + live directory listing (or a pasted git URL). */} +
+
+ + setInput(e.target.value)} + autoFocus + spellCheck={false} + placeholder={browse?.path ? `${browse.path}/ (or a git URL)` : "/data/repos/… or a git URL"} + className="flex-1 min-w-0 bg-transparent text-xs font-mono text-[var(--foreground)] focus:outline-none py-1" + /> + {isFetching && !isGit && } +
+ + {!isGit && ( +
+ {browseError ? ( +

+ {(() => { + const m = browseError instanceof Error ? browseError.message : "" + try { + return JSON.parse(m).detail ?? "Cannot read directory" + } catch { + return m || "Cannot read directory" + } + })()} +

+ ) : entries.length > 0 ? ( + entries.map((e) => ( +
+ + +
+ )) + ) : ( +

+ {filter ? "No matching sub-directories." : "No sub-directories."} +

+ )} +
+ )} +
+ + {target && ( +

+ Registering {target} + {isGit && " (cloned later via castle clone)"} +

+ )} + + setName(v.toLowerCase())} + mono + placeholder={suggestedName || "program-name"} + /> + {nameError &&

{nameError}

} + + + +
+ + +
+
+ ) +} diff --git a/app/src/components/ProgramCard.tsx b/app/src/components/ProgramCard.tsx index c38743a..8cbe475 100644 --- a/app/src/components/ProgramCard.tsx +++ b/app/src/components/ProgramCard.tsx @@ -45,25 +45,29 @@ export function ProgramCard({ + {program.description && ( +

{program.description}

+ )} + {/* A program has no kind of its own — show its deployments (name · kind). + When a deployment shares the program's name (the common 1:1 case) the + name is redundant with the card title, so show just the kind badge. Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */} {showDeployments && (program.deployments.length > 0 ? ( -
+
{program.deployments.map((d) => (
- {d.name} + {d.name !== program.id && ( + {d.name} + )}
))}
) : ( -

no deployment

+

no deployment

))} - - {program.description && ( -

{program.description}

- )}
) } diff --git a/app/src/pages/Programs.tsx b/app/src/pages/Programs.tsx index f5c078a..7f07fa2 100644 --- a/app/src/pages/Programs.tsx +++ b/app/src/pages/Programs.tsx @@ -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 (
- + 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" + > + Add program + + } + /> + {adding && ( +
+ p.id)} + onCancel={() => setAdding(false)} + /> +
+ )} + {isLoading ? (

Loading...

) : programs && programs.length > 0 ? ( diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index b5db830..90d1f53 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -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( + `/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("/programs/adopt", body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["programs"] }) + qc.invalidateQueries({ queryKey: ["graph"] }) + qc.invalidateQueries({ queryKey: ["repos"] }) + }, + }) +} + export function useRepos() { return useQuery({ queryKey: ["repos"], diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py index 5c1ad3a..b5182bc 100644 --- a/castle-api/src/castle_api/programs.py +++ b/castle-api/src/castle_api/programs.py @@ -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 `. 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 diff --git a/cli/src/castle_cli/commands/add.py b/cli/src/castle_cli/commands/add.py index 0a66acd..427f186 100644 --- a/cli/src/castle_cli/commands/add.py +++ b/cli/src/castle_cli/commands/add.py @@ -3,125 +3,43 @@ `castle program create` makes new code from a stack. `castle program add` adopts code that already exists — a local path, or a git URL to clone. It detects sensible dev verb commands so a non-castle project becomes usable without writing them by hand. + +The adopt logic itself lives in ``castle_core.adopt`` so the CLI and the API +(`POST /programs/adopt`, the dashboard's "Add program") behave identically. """ from __future__ import annotations import argparse -import tomllib -from pathlib import Path + +from castle_core.adopt import AdoptError, build_adopted_program from castle_cli.config import load_config, save_config -from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec - - -def _is_git_url(s: str) -> bool: - return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git") - - -def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]: - """Detect (stack, commands) for a source dir. - - Returns a stack name when the project fits a known one (so it inherits those - defaults), otherwise an explicit commands map. `add` adopts source only; the - deployment (and thus kind) is declared separately, so no kind is inferred here. - """ - commands: dict[str, list[list[str]]] = {} - - pyproject = src / "pyproject.toml" - if pyproject.exists(): - try: - data = tomllib.loads(pyproject.read_text()) - except (OSError, tomllib.TOMLDecodeError): - data = {} - deps = " ".join(data.get("project", {}).get("dependencies", [])) - stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli" - return stack, commands - - if (src / "Cargo.toml").exists(): - commands = { - "build": [["cargo", "build", "--release"]], - "test": [["cargo", "test"]], - "lint": [["cargo", "clippy"]], - "run": [["cargo", "run"]], - } - return None, commands - - if (src / "package.json").exists(): - commands = { - "build": [["pnpm", "build"]], - "test": [["pnpm", "test"]], - "lint": [["pnpm", "lint"]], - } - return None, commands - - if (src / "Makefile").exists() or (src / "makefile").exists(): - commands = {"build": [["make"]], "test": [["make", "test"]]} - return None, commands - - return None, commands def run_add(args: argparse.Namespace) -> int: """Adopt an existing repo as a program.""" config = load_config() - target = args.target - repo_url: str | None = None - source: str | None = None - - if _is_git_url(target): - repo_url = target - name = args.name or Path(target.rstrip("/")).name.removesuffix(".git") - # Default local clone location; cloned later via `castle clone`. - source = str(config.repos_dir / name) - src_path = Path(source) - else: - src_path = Path(target).expanduser().resolve() - if not src_path.exists(): - print(f"Error: path does not exist: {src_path}") - return 1 - source = str(src_path) - name = args.name or src_path.name - - if name in config.programs or config.deployments_named(name): - print(f"Error: '{name}' already exists in castle.yaml") + try: + adopted = build_adopted_program( + config, args.target, name=args.name, description=args.description + ) + except AdoptError as e: + print(f"Error: {e}") return 1 - # Detect verbs from the working copy if we have one on disk. `kind` is derived - # from a deployment, not stored on the program — so `castle add` adopts the - # source only; declare a deployment separately (castle service/job create). - stack: str | None = None - detected_commands: dict[str, list[list[str]]] = {} - if src_path.exists(): - stack, detected_commands = _detect(src_path) - - prog = ProgramSpec( - id=name, - description=args.description or f"Adopted from {target}", - source=source, - stack=stack, - repo=repo_url, - ) - # `build` is declared via BuildSpec; every other verb via CommandsSpec. - if detected_commands: - build_cmds = detected_commands.pop("build", None) - if build_cmds: - prog.build = BuildSpec(commands=build_cmds) - if detected_commands: - prog.commands = CommandsSpec.model_validate(detected_commands) - - config.programs[name] = prog + config.programs[adopted.name] = adopted.spec save_config(config) - print(f"Adopted '{name}' as a program.") - print(f" source: {source}") - if repo_url: - print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)") - if stack: - print(f" stack: {stack} (verbs inherited from stack defaults)") - elif detected_commands: - print(f" commands detected: {', '.join(sorted(detected_commands))}") + print(f"Adopted '{adopted.name}' as a program.") + print(f" source: {adopted.source}") + if adopted.repo: + print(f" repo: {adopted.repo} (run 'castle clone {adopted.name}' to fetch it)") + if adopted.stack: + print(f" stack: {adopted.stack} (verbs inherited from stack defaults)") + elif adopted.commands: + print(f" commands detected: {', '.join(adopted.commands)}") else: print(" no stack/commands detected — declare verbs in castle.yaml as needed") return 0 diff --git a/core/src/castle_core/adopt.py b/core/src/castle_core/adopt.py new file mode 100644 index 0000000..435a737 --- /dev/null +++ b/core/src/castle_core/adopt.py @@ -0,0 +1,157 @@ +"""Adopt an existing repo as a program — shared by the CLI (`castle program add`) +and the API (`POST /programs/adopt`). + +`create` scaffolds new code from a stack; `add` adopts code that already exists — +a local path, or a git URL to clone later. Keeping the target parsing, stack / +command sniffing, and ProgramSpec build here means both front-ends behave +identically (the logic used to live only in the CLI). +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +from castle_core.config import CastleConfig +from castle_core.manifest import BuildSpec, CommandsSpec, ProgramSpec + + +class AdoptError(ValueError): + """A program can't be adopted (bad path, or the name already exists).""" + + +def is_git_url(s: str) -> bool: + return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git") + + +def looks_like_program(src: Path) -> bool: + """Whether a directory holds something castle can adopt (a project manifest or + a git repo). Used to flag candidates in the filesystem browser.""" + return ( + (src / ".git").exists() + or (src / "pyproject.toml").exists() + or (src / "Cargo.toml").exists() + or (src / "package.json").exists() + or (src / "Makefile").exists() + or (src / "makefile").exists() + ) + + +def detect_stack_commands(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]: + """Detect (stack, commands) for a source dir. + + Returns a stack name when the project fits a known one (so it inherits those + defaults), otherwise an explicit commands map. Adoption takes source only; the + deployment (and thus kind) is declared separately, so no kind is inferred here. + """ + commands: dict[str, list[list[str]]] = {} + + pyproject = src / "pyproject.toml" + if pyproject.exists(): + try: + data = tomllib.loads(pyproject.read_text()) + except (OSError, tomllib.TOMLDecodeError): + data = {} + deps = " ".join(data.get("project", {}).get("dependencies", [])) + stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli" + return stack, commands + + if (src / "Cargo.toml").exists(): + commands = { + "build": [["cargo", "build", "--release"]], + "test": [["cargo", "test"]], + "lint": [["cargo", "clippy"]], + "run": [["cargo", "run"]], + } + return None, commands + + if (src / "package.json").exists(): + commands = { + "build": [["pnpm", "build"]], + "test": [["pnpm", "test"]], + "lint": [["pnpm", "lint"]], + } + return None, commands + + if (src / "Makefile").exists() or (src / "makefile").exists(): + commands = {"build": [["make"]], "test": [["make", "test"]]} + return None, commands + + return None, commands + + +@dataclass +class Adopted: + """The result of building a program spec from an adopt target — the caller + persists it (``config.programs[name] = spec``; then save/write).""" + + name: str + spec: ProgramSpec + source: str + stack: str | None = None + repo: str | None = None + commands: list[str] = field(default_factory=list) + + +def build_adopted_program( + config: CastleConfig, + target: str, + name: str | None = None, + description: str = "", +) -> Adopted: + """Build (but do not save) a ProgramSpec adopting ``target``. + + ``target`` is a local path or a git URL. Raises :class:`AdoptError` if a local + path doesn't exist or the resolved name already exists in the config. Does not + mutate ``config`` — the caller assigns ``config.programs[name]`` and persists. + """ + repo_url: str | None = None + + if is_git_url(target): + repo_url = target + name = name or Path(target.rstrip("/")).name.removesuffix(".git") + # Default local clone location; cloned later via `castle clone`. + source = str(config.repos_dir / name) + src_path = Path(source) + else: + src_path = Path(target).expanduser().resolve() + if not src_path.exists(): + raise AdoptError(f"path does not exist: {src_path}") + source = str(src_path) + name = name or src_path.name + + if name in config.programs or config.deployments_named(name): + raise AdoptError(f"'{name}' already exists in castle.yaml") + + # Detect verbs from the working copy if we have one on disk. `kind` is derived + # from a deployment, not stored on the program — so adoption takes source only; + # declare a deployment separately (castle service/job create). + stack: str | None = None + detected: dict[str, list[list[str]]] = {} + if src_path.exists(): + stack, detected = detect_stack_commands(src_path) + + spec = ProgramSpec( + id=name, + description=description or f"Adopted from {target}", + source=source, + stack=stack, + repo=repo_url, + ) + # `build` is declared via BuildSpec; every other verb via CommandsSpec. + if detected: + build_cmds = detected.pop("build", None) + if build_cmds: + spec.build = BuildSpec(commands=build_cmds) + if detected: + spec.commands = CommandsSpec.model_validate(detected) + + return Adopted( + name=name, + spec=spec, + source=source, + stack=stack, + repo=repo_url, + commands=sorted(detected), + )