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

@@ -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 (
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Add program</h3>
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
<X size={16} />
</button>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
{error}
</div>
)}
{/* Editable path bar + live directory listing (or a pasted git URL). */}
<div className="border border-[var(--border)] rounded">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-black/20">
<button
onClick={() => browse?.parent && setInput(browse.parent + "/")}
disabled={isGit || !browse?.parent}
title="Up one level"
className="text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30 shrink-0"
>
<ArrowUp size={14} />
</button>
<input
value={input}
onChange={(e) => 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 && <span className="text-[10px] text-[var(--muted)] shrink-0"></span>}
</div>
{!isGit && (
<div className="max-h-56 overflow-y-auto">
{browseError ? (
<p className="text-xs text-red-400 px-3 py-3">
{(() => {
const m = browseError instanceof Error ? browseError.message : ""
try {
return JSON.parse(m).detail ?? "Cannot read directory"
} catch {
return m || "Cannot read directory"
}
})()}
</p>
) : entries.length > 0 ? (
entries.map((e) => (
<div
key={e.path}
className="flex items-center gap-2 px-3 py-1.5 text-sm border-b border-[var(--border)]/50 last:border-0"
>
<button
onClick={() => setInput(e.path + "/")}
className="flex items-center gap-2 min-w-0 flex-1 text-left hover:text-[var(--primary)]"
title="Open"
>
{e.is_git ? (
<GitBranch size={13} className="shrink-0 text-[var(--primary)]" />
) : (
<Folder size={13} className="shrink-0 text-[var(--muted)]" />
)}
<span className="truncate font-mono">{e.name}</span>
</button>
<button
onClick={() => setInput(e.path)}
className={`shrink-0 px-2 py-0.5 text-xs rounded border transition-colors ${
e.is_program
? "border-[var(--primary)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white"
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{target === e.path ? (
<span className="flex items-center gap-1">
<Check size={11} /> picked
</span>
) : (
"pick"
)}
</button>
</div>
))
) : (
<p className="text-xs text-[var(--muted)] px-3 py-3">
{filter ? "No matching sub-directories." : "No sub-directories."}
</p>
)}
</div>
)}
</div>
{target && (
<p className="text-xs text-[var(--muted)]">
Registering <span className="font-mono text-[var(--foreground)]">{target}</span>
{isGit && " (cloned later via castle clone)"}
</p>
)}
<TextField
label="Name"
value={name}
onChange={(v) => setName(v.toLowerCase())}
mono
placeholder={suggestedName || "program-name"}
/>
{nameError && <p className="text-xs text-red-400 -mt-2 ml-28 sm:ml-36">{nameError}</p>}
<TextField label="Description" value={description} onChange={setDescription} />
<div className="flex justify-end gap-2 pt-2">
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
>
Cancel
</button>
<button
onClick={submit}
disabled={!target || !!nameError || adopt.isPending}
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
>
{adopt.isPending ? "Registering…" : "Register"}
</button>
</div>
</div>
)
}

View File

@@ -45,25 +45,29 @@ export function ProgramCard({
<StackBadge stack={program.stack} /> <StackBadge stack={program.stack} />
</div> </div>
{program.description && (
<p className="text-sm text-[var(--muted)] mb-2">{program.description}</p>
)}
{/* A program has no kind of its own — show its deployments (name · kind). {/* 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. */} Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */}
{showDeployments && {showDeployments &&
(program.deployments.length > 0 ? ( (program.deployments.length > 0 ? (
<div className="flex flex-col gap-1 mb-2"> <div className="flex flex-col gap-1">
{program.deployments.map((d) => ( {program.deployments.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs"> <div key={d.name} className="flex items-center gap-1.5 text-xs">
<span className="font-mono text-[var(--muted)]">{d.name}</span> {d.name !== program.id && (
<span className="font-mono text-[var(--muted)]">{d.name}</span>
)}
<KindBadge kind={d.kind} /> <KindBadge kind={d.kind} />
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p> <p className="text-xs text-[var(--muted)] italic">no deployment</p>
))} ))}
{program.description && (
<p className="text-sm text-[var(--muted)]">{program.description}</p>
)}
</div> </div>
) )
} }

View File

@@ -1,17 +1,41 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { usePrograms } from "@/services/api/hooks" import { usePrograms } from "@/services/api/hooks"
import { ProgramList } from "@/components/ProgramList" import { ProgramList } from "@/components/ProgramList"
import { MonorepoBanner } from "@/components/MonorepoBanner" import { MonorepoBanner } from "@/components/MonorepoBanner"
import { PageHeader } from "@/components/PageHeader" import { PageHeader } from "@/components/PageHeader"
import { AddProgramForm } from "@/components/AddProgramForm"
export function Programs() { export function Programs() {
const { data: programs, isLoading } = usePrograms() const { data: programs, isLoading } = usePrograms()
const [adding, setAdding] = useState(false)
return ( return (
<div className="max-w-6xl mx-auto px-6 py-8"> <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 /> <MonorepoBanner />
{adding && (
<div className="mb-6 max-w-2xl">
<AddProgramForm
existingNames={(programs ?? []).map((p) => p.id)}
onCancel={() => setAdding(false)}
/>
</div>
)}
{isLoading ? ( {isLoading ? (
<p className="text-[var(--muted)]">Loading...</p> <p className="text-[var(--muted)]">Loading...</p>
) : programs && programs.length > 0 ? ( ) : programs && programs.length > 0 ? (

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() { export function useRepos() {
return useQuery({ return useQuery({
queryKey: ["repos"], queryKey: ["repos"],

View File

@@ -3,10 +3,19 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core import git 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_core.stacks import available_actions, available_stacks, run_action
from castle_api import stream from castle_api import stream
@@ -22,6 +31,103 @@ def list_stacks() -> list[str]:
return available_stacks() 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 # 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 # build/apply/restart — converge stays an explicit, separate step). Declared

View File

@@ -3,125 +3,43 @@
`castle program create` makes new code from a stack. `castle program add` adopts code that `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 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. 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 from __future__ import annotations
import argparse 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.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: def run_add(args: argparse.Namespace) -> int:
"""Adopt an existing repo as a program.""" """Adopt an existing repo as a program."""
config = load_config() config = load_config()
target = args.target
repo_url: str | None = None try:
source: str | None = None adopted = build_adopted_program(
config, args.target, name=args.name, description=args.description
if _is_git_url(target): )
repo_url = target except AdoptError as e:
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git") print(f"Error: {e}")
# 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")
return 1 return 1
# Detect verbs from the working copy if we have one on disk. `kind` is derived config.programs[adopted.name] = adopted.spec
# 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
save_config(config) save_config(config)
print(f"Adopted '{name}' as a program.") print(f"Adopted '{adopted.name}' as a program.")
print(f" source: {source}") print(f" source: {adopted.source}")
if repo_url: if adopted.repo:
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)") print(f" repo: {adopted.repo} (run 'castle clone {adopted.name}' to fetch it)")
if stack: if adopted.stack:
print(f" stack: {stack} (verbs inherited from stack defaults)") print(f" stack: {adopted.stack} (verbs inherited from stack defaults)")
elif detected_commands: elif adopted.commands:
print(f" commands detected: {', '.join(sorted(detected_commands))}") print(f" commands detected: {', '.join(adopted.commands)}")
else: else:
print(" no stack/commands detected — declare verbs in castle.yaml as needed") print(" no stack/commands detected — declare verbs in castle.yaml as needed")
return 0 return 0

View File

@@ -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),
)