Files
ai-agent/backend/templates.py
Gabriel Vidal 73903ec6b7 feat(ai-agent): run standalone — no homelab mounts required
The GOAL's "generic first-run setup" / one-liner `docker run` needs the image to
boot with none of the ~8 host paths the homelab compose mounts. It nearly did:
every catalog already degraded to an empty list except /api/templates, which
500'd on a missing templates dir.

- templates.py: a missing templates mount returns [] instead of raising. An
  absent catalog is a valid state; only a *named* template that isn't there is
  still a 404.
- docker-compose.standalone.yml: the generic shape — a workspace + a data volume
  and nothing else required, no Traefik, no homelab paths.
- scripts/standalone-smoke.sh: boots the image on a throwaway workspace and
  asserts every catalog endpoint + the PWA shell answer 200, and that no
  background thread left a traceback. This is the regression guard — it's what
  caught the templates 500.

Verified: smoke passes on the new image, fails on the old one. Also records the
two things standalone still can't do (build off-homelab: .npmrc pulls @gabvdl/ui
from the host verdaccio; spawn sessions: needs the host sidecar) in GOAL.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 05:39:10 +02:00

293 lines
9.8 KiB
Python

"""Browse the homelab project scaffolding templates (projects/templates/).
Merged in from the former standalone `project-templates` service: lists each
template under TEMPLATES_DIR, walks its file tree and serves individual text
file contents so a scaffold can be previewed before use. Pure helpers — the
FastAPI routes live in main.py.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from fastapi import HTTPException
TEMPLATES_DIR = Path(
os.environ.get("TEMPLATES_DIR", "/workspace/projects/templates")).resolve()
# Directories we never descend into or list, and the cap for file previews.
IGNORE_DIRS = {
"node_modules", ".git", "dist", ".turbo", "__pycache__",
".venv", "venv", ".next", "build", ".cache", "pb_data",
}
# Files that are pure noise in a template browser.
IGNORE_FILES = {".DS_Store"}
MAX_FILE_BYTES = 512 * 1024 # 512 KB cap for a single preview
# Extensions we treat as text and are willing to serve inline.
TEXT_EXT = {
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".md", ".mdx",
".css", ".scss", ".html", ".htm", ".svg", ".txt", ".yml", ".yaml",
".toml", ".sh", ".bash", ".py", ".env", ".example", ".gitignore",
".dockerignore", ".lock", ".cfg", ".ini", ".conf", ".xml", ".rs",
".go", ".sql", ".gitkeep",
}
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _template_dir(name: str) -> Path:
"""Resolve a template name to a directory inside TEMPLATES_DIR, safely."""
if not name or "/" in name or name.startswith("."):
raise HTTPException(400, "bad template name")
target = (TEMPLATES_DIR / name).resolve()
if target.parent != TEMPLATES_DIR or not target.is_dir():
raise HTTPException(404, "template not found")
return target
def _resolve_in(base: Path, rel: str) -> Path:
"""Safely resolve a relative path inside `base`, blocking traversal."""
target = (base / rel).resolve()
if base != target and base not in target.parents:
raise HTTPException(400, "path escapes template")
return target
def _is_text(path: Path) -> bool:
name = path.name
if name in {"Dockerfile", "Makefile", "LICENSE", "README"}:
return True
return path.suffix.lower() in TEXT_EXT
def _walk_stats(root: Path) -> tuple[int, int]:
"""Return (file_count, total_bytes) ignoring IGNORE_DIRS."""
files = 0
size = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS]
for fn in filenames:
if fn in IGNORE_FILES:
continue
files += 1
try:
size += (Path(dirpath) / fn).stat().st_size
except OSError:
pass
return files, size
def _read_readme(root: Path) -> str | None:
for cand in ("README.md", "README", "readme.md"):
p = root / cand
if p.is_file():
try:
return p.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
return None
def _read_package_json(root: Path) -> dict | None:
p = root / "package.json"
if not p.is_file():
return None
try:
return json.loads(p.read_text(encoding="utf-8", errors="replace"))
except (OSError, json.JSONDecodeError):
return None
def _clean(text: str) -> str:
"""Drop unfilled scaffold placeholders like @@DESCRIPTION@@."""
return "" if "@@" in text else text
def _description(root: Path, pkg: dict | None, readme: str | None) -> str:
if pkg and isinstance(pkg.get("description"), str):
desc = _clean(pkg["description"].strip())
if desc:
return desc
if readme:
for line in readme.splitlines():
s = line.strip()
if s and not s.startswith("#") and not s.startswith("!["):
cleaned = _clean(s)
if cleaned:
return cleaned
return ""
# Subdirs a fullstack template might keep its frontend / backend under, so the
# stack tags are right even when package.json / requirements.txt aren't at root.
_FRONTEND_DIRS = ("", "frontend", "web", "ui", "client", "app")
_BACKEND_DIRS = ("", "backend", "server", "api", "app")
def _stack(root: Path, pkg: dict | None) -> list[str]:
"""Best-effort detection of the tech in a template, as short tags."""
tags: list[str] = []
# Gather npm deps from a root package.json and any nested frontend one.
deps: dict = {}
if pkg:
deps.update(pkg.get("dependencies", {}))
deps.update(pkg.get("devDependencies", {}))
for sub in _FRONTEND_DIRS:
if sub:
nested = _read_package_json(root / sub)
if nested:
deps.update(nested.get("dependencies", {}))
deps.update(nested.get("devDependencies", {}))
has = lambda *names: any(n in deps for n in names) # noqa: E731
def _exists(subs: tuple[str, ...], *names: str) -> bool:
return any((root / sub / n).exists() for sub in subs for n in names)
if has("react", "react-dom"):
tags.append("React")
if has("vue"):
tags.append("Vue")
if has("svelte"):
tags.append("Svelte")
if has("vite", "@vitejs/plugin-react"):
tags.append("Vite")
if has("typescript") or _exists(_FRONTEND_DIRS, "tsconfig.json"):
tags.append("TypeScript")
if has("tailwindcss", "@tailwindcss/vite"):
tags.append("Tailwind")
if has("rxdb"):
tags.append("RxDB")
# Python / FastAPI from requirements.txt or pyproject in root or a backend dir.
py_files = [
(root / sub / n)
for sub in _BACKEND_DIRS
for n in ("requirements.txt", "pyproject.toml")
if (root / sub / n).is_file()
]
if py_files:
tags.append("Python")
blob = ""
for p in py_files:
try:
blob += p.read_text(encoding="utf-8", errors="replace").lower()
except OSError:
pass
if "fastapi" in blob:
tags.append("FastAPI")
if (root / "Dockerfile").exists():
tags.append("Docker")
if (root / "docker-compose.yml").exists() or (root / "compose.yml").exists():
tags.append("Compose")
if (root / "pocketbase").exists():
tags.append("PocketBase")
return tags
def _tree(root: Path, base: Path) -> list[dict]:
"""Build a sorted nested file tree (dirs first), ignoring IGNORE_DIRS."""
out: list[dict] = []
try:
entries = sorted(
root.iterdir(),
key=lambda p: (p.is_file(), p.name.lower()),
)
except OSError:
return out
for entry in entries:
if entry.is_dir():
if entry.name in IGNORE_DIRS:
continue
out.append({
"name": entry.name,
"path": str(entry.relative_to(base)),
"type": "dir",
"children": _tree(entry, base),
})
else:
if entry.name in IGNORE_FILES:
continue
try:
size = entry.stat().st_size
except OSError:
size = 0
out.append({
"name": entry.name,
"path": str(entry.relative_to(base)),
"type": "file",
"size": size,
"text": _is_text(entry),
})
return out
# --------------------------------------------------------------------------- #
# public API (consumed by main.py routes)
# --------------------------------------------------------------------------- #
def list_templates() -> list[dict]:
# Absent scaffolds are a valid state, not an error: with no templates mount
# (a standalone `docker run`) the tab shows an empty gallery, like every
# other catalog here. Only a *named* template that isn't there is a 404.
if not TEMPLATES_DIR.is_dir():
return []
items: list[dict] = []
for entry in sorted(TEMPLATES_DIR.iterdir(), key=lambda p: p.name.lower()):
if not entry.is_dir() or entry.name in IGNORE_DIRS or entry.name.startswith("."):
continue
pkg = _read_package_json(entry)
readme = _read_readme(entry)
files, size = _walk_stats(entry)
items.append({
"name": entry.name,
"description": _description(entry, pkg, readme),
"stack": _stack(entry, pkg),
"fileCount": files,
"sizeBytes": size,
"hasReadme": readme is not None,
})
return items
def template_detail(name: str) -> dict:
root = _template_dir(name)
pkg = _read_package_json(root)
readme = _read_readme(root)
files, size = _walk_stats(root)
scripts = pkg.get("scripts", {}) if pkg else {}
deps = sorted((pkg.get("dependencies", {}) if pkg else {}).keys())
return {
"name": name,
"description": _description(root, pkg, readme),
"stack": _stack(root, pkg),
"fileCount": files,
"sizeBytes": size,
"readme": readme,
"scripts": scripts,
"dependencies": deps,
"tree": _tree(root, root),
}
def template_file(name: str, path: str) -> str:
root = _template_dir(name)
target = _resolve_in(root, path)
if not target.is_file():
raise HTTPException(404, "file not found")
if not _is_text(target):
raise HTTPException(415, "binary file — preview unavailable")
try:
size = target.stat().st_size
except OSError:
raise HTTPException(404, "file not found")
if size > MAX_FILE_BYTES:
raise HTTPException(413, f"file too large ({size} bytes)")
try:
return target.read_text(encoding="utf-8", errors="replace")
except OSError:
raise HTTPException(500, "could not read file")