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>
293 lines
9.8 KiB
Python
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")
|