10 Commits
3 changed files with 194 additions and 13 deletions

No files matched your search

+27 -4
View File
@@ -1,6 +1,6 @@
# newproject
Composes a project from the templates in this organization: `infra` at the root, then `backend-python`, `frontend-svelte` and `caddy` on top, each with its own answers file in `.copier/`.
Composes a project from the templates in this organization: `infra` at the root, then `backend-python`, `frontend-svelte`, `blank` and `caddy` on top, each with its own answers file in `.copier/`.
```sh
source ~/projects/templates/newproject/shell.sh
@@ -9,12 +9,35 @@ updateproject # every part
updateproject backend # one part
```
`newproject` asks for the parts, the backend language, the frontend framework and how the frontend is served, then hands over to each template's own questions. Every prompt has a flag for non-interactive use:
`newproject` asks for the description, the parts, the backend language, the frontend framework, how the frontend is served and the MCP servers for `.mcp.json`, then hands over to each template's own questions. Every prompt has a flag for non-interactive use:
```sh
newproject ~/projects/my-thing --parts backend,frontend,caddy --backend python --frontend svelte --serve bun --defaults
newproject ~/projects/my-thing --parts backend,frontend,caddy --backend python --frontend svelte --serve bun --mcp context7,svelte --defaults
```
One part alone is rendered at the root of the project; two parts live in `backend/` and `frontend/`. After rendering, `make env` creates `.env` and every `docker-compose.override.yml` from their examples. Templates are cloned from `https://git.kotikot.com/templates` unless `TEMPLATES_DIR` points at a directory with local checkouts; `TEMPLATES_REF=HEAD` renders their working trees instead of the latest tag.
`other` as the backend or the frontend renders the `blank` template: the part's compose files, Makefile and `.env.example` with no code, for a stack that has no template of its own, next to any other part.
One part alone is rendered at the root of the project; two parts live in `backend/` and `frontend/`. After rendering, `make env` creates `.env` and every `docker-compose.override.yml` from their examples. `updateproject` needs a clean working tree and commits each part's update separately, so the next part starts from a clean tree again; conflicts stay in those commits for you to resolve. Templates are cloned from `https://git.kotikot.com/templates` unless `TEMPLATES_DIR` points at a directory with local checkouts; `TEMPLATES_REF=HEAD` renders their working trees instead of the latest tag.
## Renovate
A new repo needs the `renovate`, `wait-3d` and `deps-code` labels before the bot
ever runs against it: Renovate fails on a label that does not exist, and until
today these were created by hand for every repo.
```sh
giteaproject ms-agents/my-thing
```
It creates the labels and, if the repo has no `renovate.jsonc` yet, commits one
that is nothing but an `extends` on the shared preset in `templates/renovate`.
Projects rendered by `newproject` already carry that file from the `infra`
template, so for them the command only does the labels.
**Extending the preset does not subscribe the repo to anything.** The bot walks
an explicit list, so the repo also has to be added to `repositories` in
`projects/personal/renovate/renovate-config.js` of `infra/komodo`, and the
`renovate` team of its organization needs write access to it. Miss that step and
the config sits there doing nothing, silently.
Requires `uv`; `bun`, `docker` and `pre-commit` are used by the generated projects.
+1
View File
@@ -6,3 +6,4 @@ fi
newproject() { uv run --script "$TEMPLATES_HOME/templates.py" new "$@"; }
updateproject() { uv run --script "$TEMPLATES_HOME/templates.py" update "$@"; }
giteaproject() { uv run --script "$TEMPLATES_HOME/templates.py" gitea "$@"; }
+166 -9
View File
@@ -14,9 +14,11 @@ 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"}
BLANK = "blank"
BACKENDS = {"python": "backend-python", "other": BLANK}
FRONTENDS = {"svelte": "frontend-svelte", "other": BLANK}
SERVE = ["bun", "static", "api"]
MCP = ["context7", "svelte"]
def template(name: str) -> str:
@@ -58,6 +60,7 @@ 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))
description = args.description if args.description is not None else ask_text("Description", 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:
@@ -65,40 +68,189 @@ def new(args: argparse.Namespace) -> None:
if "frontend" in parts:
frontend = args.frontend or ask_select("Frontend", list(FRONTENDS), "svelte")
serve = "bun"
if frontend:
if frontend and FRONTENDS[frontend] != BLANK:
options = SERVE if backend else SERVE[:-1]
serve = args.serve or ask_select("Frontend served by", options, "bun")
mcp_default = ["svelte"] if frontend and FRONTENDS[frontend] == "frontend-svelte" else []
mcp = args.mcp.split(",") if args.mcp is not None else ask_checkbox("MCP servers", MCP, mcp_default)
mcp = [m for m in mcp if m]
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)
def copy(name: str, data: dict, answers_file: str | None = None) -> None:
run_copy(template(name), dest, data=data, answers_file=answers_file, unsafe=True,
defaults=args.defaults, vcs_ref=REF)
def copy_part(part: str, name: str, data: dict) -> None:
if name == BLANK:
copy(name, {**identity, "layout": layout, "part": part}, f".copier/{part}.yml")
else:
copy(name, {**identity, "layout": layout, **data})
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})
copy("infra", {"project_name": name, "project_description": description, "layout": "single" if single else "parts",
"parts": [] if single else stack, "mcp_servers": mcp})
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")})
copy_part("backend", BACKENDS[backend], {"project_description": description, "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})
copy_part("frontend", FRONTENDS[frontend], {"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_message(answers: Path) -> str:
"""Conventional commit for a template update, with the version it moved to."""
ref = ""
for line in answers.read_text(encoding="utf-8").splitlines():
if line.startswith("_commit:"):
ref = line.split(":", 1)[1].strip().strip("'\"")
break
scope = answers.stem
return f"chore({scope}): update template to {ref}" if ref else f"chore({scope}): update template"
def refresh_locks(part: str) -> None:
root = Path(part) if Path(part, "Makefile").is_file() else Path()
if (root / "uv.lock").is_file():
subprocess.run(["uv", "lock", "-q"], cwd=root, check=True)
if (root / "bun.lock").is_file():
subprocess.run(["bun", "install", "--silent"], cwd=root, check=True)
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)
conflicts = subprocess.run(["git", "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True, check=False).stdout.split()
if conflicts:
sys.exit(f"{file.stem}: resolve conflicts, then commit and rerun:\n " + "\n ".join(conflicts))
refresh_locks(file.stem)
if git_dirty():
subprocess.run(["git", "add", "-A"], check=True)
subprocess.run(["git", "commit", "-qm", update_message(file)], check=True)
print(f"committed {file.stem} update")
GITEA = os.environ.get("GITEA_URL", "https://git.kotikot.com").rstrip("/")
RENOVATE_FILE = "renovate.jsonc"
RENOVATE_STUB = """{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>templates/renovate"]
}
"""
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: labels and the preset stub."""
import base64
import json
import urllib.error
import urllib.request
token = gitea_token()
def call(method: str, path: str, body: dict | None = None):
request = urllib.request.Request(
f"{GITEA}/api/v1{path}",
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
return json.loads(raw) if raw else None
# Gitea happily creates a second label with the same name, so the only way to
# stay idempotent is to look first.
existing = {label["name"] for label in call("GET", f"/repos/{args.repo}/labels?limit=100") or []}
for name, color, description in RENOVATE_LABELS:
if name in existing:
print(f"label {name}: already there")
continue
call("POST", f"/repos/{args.repo}/labels",
{"name": name, "color": color, "description": description})
print(f"label {name}: created")
try:
call("GET", f"/repos/{args.repo}/contents/{RENOVATE_FILE}")
print(f"{RENOVATE_FILE}: already there")
except urllib.error.HTTPError as error:
if error.code != 404:
sys.exit(f"{RENOVATE_FILE}: {error.code} {error.read()[:200]!r}")
call("POST", f"/repos/{args.repo}/contents/{RENOVATE_FILE}", {
"content": base64.b64encode(RENOVATE_STUB.encode()).decode(),
"message": "chore(renovate): extend the shared preset",
})
print(f"{RENOVATE_FILE}: created")
org, name = args.repo.split("/", 1)
teams = call("GET", f"/orgs/{org}/teams?limit=50") or []
team = next((t for t in teams if t["name"] == "renovate"), None)
if team is None:
team = call("POST", f"/orgs/{org}/teams", {
"name": "renovate",
"description": "Renovate bot: write on listed repos only, not the whole org",
"includes_all_repositories": False,
"permission": "none",
"units": ["repo.code", "repo.issues", "repo.pulls"],
"units_map": {"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"},
"can_create_org_repo": False,
})
print(f"team renovate in {org}: created")
call("PUT", f"/teams/{team['id']}/members/renovate")
print("bot renovate: added to the team")
members = {m["login"] for m in call("GET", f"/teams/{team['id']}/members?limit=50") or []}
if "renovate" not in members:
call("PUT", f"/teams/{team['id']}/members/renovate")
print("bot renovate: added to the team")
repos = {r["name"] for r in call("GET", f"/teams/{team['id']}/repos?limit=50") or []}
if name in repos:
print(f"team access to {name}: already there")
else:
call("PUT", f"/teams/{team['id']}/repos/{org}/{name}")
print(f"team access to {name}: granted")
print(
"\nOne thing is still manual, and it does not happen by itself: the repo has\n"
"to be added to `repositories` in projects/personal/renovate/renovate-config.js\n"
"of infra/komodo. Extending the preset subscribes nothing - the bot only visits\n"
"repos named in that list."
)
def main() -> None:
@@ -108,10 +260,12 @@ def main() -> None:
n.add_argument("dest")
n.add_argument("--name")
n.add_argument("--slug")
n.add_argument("--description")
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("--mcp", help="comma-separated: context7,svelte; empty for none")
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")
@@ -119,6 +273,9 @@ def main() -> None:
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="Set a repo up for Renovate: labels and preset stub")
g.add_argument("repo", help="org/name on Gitea")
g.set_defaults(func=gitea)
args = parser.parse_args()
args.func(args)