# /// script # requires-python = ">=3.12" # dependencies = ["copier>=9.10", "questionary>=2.1"] # /// import argparse import os import re import subprocess import sys from pathlib import Path import questionary from copier import run_copy, run_update SOURCE = os.environ.get("TEMPLATES_DIR", "https://git.kotikot.com/templates") REF = os.environ.get("TEMPLATES_REF") BACKENDS = {"python": "backend-python"} FRONTENDS = {"svelte": "frontend-svelte"} SERVE = ["bun", "static", "api"] def template(name: str) -> str: local = Path(SOURCE) / name return str(local) if local.is_dir() else f"{SOURCE}/{name}.git" def git_config(key: str) -> str: result = subprocess.run(["git", "config", key], capture_output=True, text=True, check=False) return result.stdout.strip() def interactive() -> bool: return sys.stdin.isatty() def ask_text(prompt: str, default: str) -> str: return questionary.text(prompt, default=default).unsafe_ask() if interactive() else default def ask_select(prompt: str, choices: list[str], default: str) -> str: if not interactive(): return default return questionary.select(prompt, choices=choices, default=default).unsafe_ask() def ask_checkbox(prompt: str, choices: list[str], default: list[str]) -> list[str]: if not interactive(): return default items = [questionary.Choice(c, checked=c in default) for c in choices] return questionary.checkbox(prompt, choices=items).unsafe_ask() def slugify(name: str) -> str: return re.sub(r"[^a-z0-9]", "_", name.lower()) def new(args: argparse.Namespace) -> None: dest = Path(args.dest).resolve() name = args.name or ask_text("Project name", dest.name) slug = args.slug or ask_text("Slug", slugify(name)) parts = args.parts.split(",") if args.parts else ask_checkbox("Parts", ["backend", "frontend", "caddy"], ["backend", "frontend", "caddy"]) backend = frontend = None if "backend" in parts: backend = args.backend or ask_select("Backend", list(BACKENDS), "python") if "frontend" in parts: frontend = args.frontend or ask_select("Frontend", list(FRONTENDS), "svelte") serve = "bun" if frontend: options = SERVE if backend else SERVE[:-1] serve = args.serve or ask_select("Frontend served by", options, "bun") stack = [p for p in ("backend", "frontend") if p in parts] single = len(stack) == 1 routes = [r for r, ok in (("api", backend), ("frontend", frontend and serve != "api")) if ok] def copy(name: str, data: dict) -> None: run_copy(template(name), dest, data=data, unsafe=True, defaults=args.defaults, vcs_ref=REF) dest.mkdir(parents=True, exist_ok=True) identity = {"project_name": name, "project_slug": slug} copy("infra", {"project_name": name, "layout": "single" if single else "parts", "parts": [] if single else stack}) layout = "root" if single else "part" if backend: copy(BACKENDS[backend], {**identity, "layout": layout, "serve_spa": serve == "api", "author_name": git_config("user.name"), "author_email": git_config("user.email")}) if frontend: copy(FRONTENDS[frontend], {**identity, "layout": layout, "serve": serve}) if "caddy" in parts: copy("caddy", {"project_slug": slug, "routes": routes}) if (dest / "Makefile").is_file(): subprocess.run(["make", "env"], cwd=dest, check=True) def git_dirty() -> bool: result = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, check=False) return bool(result.stdout.strip()) def update(args: argparse.Namespace) -> None: answers = sorted(Path(".copier").glob("*.yml")) if args.parts: answers = [Path(".copier") / f"{p}.yml" for p in args.parts] if not answers: sys.exit("no .copier/*.yml here") if git_dirty(): sys.exit("working tree is dirty, commit or stash first") for file in answers: run_update(".", answers_file=str(file), unsafe=True, overwrite=True, skip_answered=not args.ask, defaults=args.defaults, vcs_ref=REF) if git_dirty(): subprocess.run(["git", "add", "-A"], check=True) subprocess.run(["git", "commit", "-qm", f"Update {file.stem} template"], check=True) print(f"committed {file.stem} update") GITEA = os.environ.get("GITEA_URL", "https://git.kotikot.com").rstrip("/") RENOVATE_LABELS = [ ("renovate", "1f6feb", "Pull request from the Renovate bot"), ("wait-3d", "d29922", "Waits three days: the registry has no release timestamp, the age gate merges it"), ("deps-code", "7d8590", "Code dependencies: pull requests are collected, nothing merges itself"), ] def gitea_token() -> str: token = os.environ.get("GITEA_TOKEN", "") if token: return token path = Path.home() / ".config/gitea/token" if path.exists(): return path.read_text(encoding="utf-8").strip() sys.exit("no Gitea token: set GITEA_TOKEN or write ~/.config/gitea/token") def gitea(args: argparse.Namespace) -> None: """Prepare a Gitea repo for Renovate: the labels it fails without.""" import json import urllib.error import urllib.request token = gitea_token() for name, color, description in RENOVATE_LABELS: body = json.dumps({"name": name, "color": color, "description": description}).encode() request = urllib.request.Request( f"{GITEA}/api/v1/repos/{args.repo}/labels", data=body, method="POST", headers={"Authorization": f"token {token}", "Content-Type": "application/json"}, ) try: urllib.request.urlopen(request, timeout=30) print(f"label {name}: created") except urllib.error.HTTPError as error: if error.code in (409, 422): print(f"label {name}: already there") else: sys.exit(f"label {name}: {error.code} {error.read()[:200]!r}") print( "\nLabels done. Two things are still manual, and neither happens by itself:\n" f" 1. renovate.jsonc in {args.repo} with" ' {"extends": ["local>templates/renovate"]}\n' " 2. the repo added to `repositories` in renovate-config.js of infra/komodo,\n" " plus the bot given write access to it. Extending the preset subscribes\n" " nothing: the bot only visits repos named in that list." ) def main() -> None: parser = argparse.ArgumentParser(prog="templates") sub = parser.add_subparsers(dest="command", required=True) n = sub.add_parser("new", help="Compose a project from templates") n.add_argument("dest") n.add_argument("--name") n.add_argument("--slug") n.add_argument("--parts", help="comma-separated: backend,frontend,caddy") n.add_argument("--backend", choices=list(BACKENDS)) n.add_argument("--frontend", choices=list(FRONTENDS)) n.add_argument("--serve", choices=SERVE) n.add_argument("--defaults", action="store_true", help="accept template defaults without asking") n.set_defaults(func=new) u = sub.add_parser("update", help="Update every part from its template") u.add_argument("parts", nargs="*", help="parts to update, default all") u.add_argument("--ask", action="store_true", help="ask every question again") u.add_argument("--defaults", action="store_true") u.set_defaults(func=update) g = sub.add_parser("gitea", help="Create the Renovate labels a repo needs") g.add_argument("repo", help="org/name on Gitea") g.set_defaults(func=gitea) args = parser.parse_args() args.func(args) if __name__ == "__main__": main()