23 Commits
Author SHA1 Message Date
hh 5c43bf7e3e docs(readme): renovate finds the repo itself, the repositories list is gone 2026-10-10 04:33:08 +00:00
hh b4f3a583d4 docs: short readme 2026-10-08 21:34:50 +02:00
hh 19f08ea71c chore: drop the committed bytecode, ignore __pycache__ 2026-10-08 20:43:16 +02:00
hh 095b8eacbd fix(new): answers point at gitea when rendered from local checkouts, an existing repo is adopted with the rendered files on top 2026-10-08 04:39:38 +00:00
hh aa8ddc99ef feat(gitea): giteaproject creates the repo, sets origin over ssh and pushes main and dev 2026-10-01 02:23:44 +02:00
hh 2b332da0a2 fix(update): stop on merge conflicts instead of committing the markers 2026-10-01 02:06:55 +02:00
hh 3d85e6f29c fix(update): refresh uv.lock and bun.lock after each part update 2026-10-01 02:04:17 +02:00
hh 44ce6c04fa feat(new): one description for every part, mcp servers question 2026-10-01 02:02:05 +02:00
hh 048f29f21e feat(new): other backend or frontend from the blank template 2026-09-19 22:09:16 +02:00
hh 68034cd2f5 feat(gitea): grant the bot team access to the repo 2026-09-10 17:30:05 +00:00
hh 81d518f2c8 fix(update): conventional commit message with the template version 2026-09-10 17:04:08 +00:00
hh 2785443794 fix(gitea): check labels before creating, Gitea allows duplicates 2026-09-10 16:07:02 +00:00
hh d7e948df33 feat(gitea): commit the preset stub instead of asking for it 2026-09-10 16:06:27 +00:00
hh 454be36bec feat(gitea): create Renovate labels for a new repo 2026-09-10 16:01:09 +00:00
hh e0550cb462 Deploy hooks 2026-09-09 00:37:04 +02:00
hh 1f663c8e51 Pass project name to infra, document make env 2026-09-09 00:21:28 +02:00
hh 7ff97419e3 Override-based compose, profiles in .env, README 2026-09-09 00:18:15 +02:00
hh 79cb646dd3 TEMPLATES_REF applies to updates 2026-09-08 20:41:37 +02:00
hh c31f0b241f TEMPLATES_REF for template development 2026-09-08 20:40:21 +02:00
hh 40103e9bef Single part at the root, COMPOSE_FILE in .env 2026-09-08 20:38:27 +02:00
hh cb30b8c5d8 Answers file under .copier 2026-09-08 20:11:39 +02:00
hh 216020f152 Run under bash 3.2 2026-09-08 20:08:42 +02:00
hh 7a1774a36f Clone templates from git by default 2026-09-08 20:04:08 +02:00
5 changed files with 433 additions and 87 deletions

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
.DS_Store .DS_Store
.idea .idea
__pycache__/
+6 -15
View File
@@ -1,21 +1,12 @@
# newproject # newproject
One command that 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. Builds a project from the templates: `infra` at the root, then `backend-python`, `frontend-svelte`, `blank` and `caddy`, each with its answers in `.copier/`.
```sh ```sh
newproject ~/projects/my-thing source ~/projects/templates/newproject/shell.sh
newproject ~/projects/my-thing # asks; --parts, --backend, --frontend, --serve, --mcp, --repo, --defaults
updateproject [part] # clean tree, one commit per part
giteaproject [org/name] # repo in Gitea, origin, Renovate labels
``` ```
Templates are read from `$TEMPLATES_DIR` (default `~/projects/templates`). Every part updates independently: Templates come from Gitea (latest tag); `TEMPLATES_DIR` renders local checkouts, `TEMPLATES_REF=HEAD` their working trees. Renovate finds the repo on its own once `giteaproject` has put `renovate.jsonc` in it and the bot's team on it.
```sh
copier update -a .copier-answers.backend.yml
```
Shapes it produces:
- infrastructure only: root `docker-compose.yml`, `Makefile`, `.env`, optional `caddy/`
- backend with one or many services, each `python -m <module>` from one image
- backend and frontend as separate containers behind Caddy
- backend serving the frontend's static build from the api
- frontend only
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
TEMPLATES="${TEMPLATES_DIR:-$HOME/projects/templates}"
dest="${1:?usage: newproject <dest> [copier options]}"
shift
copier_args=("$@")
ask() {
local prompt="$1" default="$2" answer
read -r -p "$prompt [$default]: " answer
printf '%s' "${answer:-$default}"
}
yes_no() {
local answer
answer=$(ask "$1" "$2")
[[ "$answer" =~ ^[Yy] ]]
}
copy() {
uvx copier@latest copy --trust "${copier_args[@]}" "$@"
}
project_name=$(ask "Project name" "$(basename "$dest")")
default_slug=$(printf '%s' "$project_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g')
project_slug=$(ask "Slug" "$default_slug")
author_name=$(git config user.name || true)
author_email=$(git config user.email || true)
parts=()
yes_no "Backend" y && parts+=(backend)
yes_no "Frontend" y && parts+=(frontend)
with_caddy=false
yes_no "Caddy" y && with_caddy=true
serve=bun
serve_spa=false
if [[ " ${parts[*]} " == *" frontend "* ]]; then
serve=$(ask "Frontend served by (bun | static | api)" "bun")
[[ "$serve" == api ]] && serve_spa=true
fi
routes=()
[[ " ${parts[*]} " == *" backend "* ]] && routes+=(api)
[[ " ${parts[*]} " == *" frontend "* && "$serve" != api ]] && routes+=(frontend)
json_list() {
local out="[" sep=""
for item in "$@"; do out+="$sep\"$item\""; sep=","; done
printf '%s]' "$out"
}
mkdir -p "$dest"
copy -d "parts=$(json_list "${parts[@]}")" "$TEMPLATES/infra" "$dest"
if [[ " ${parts[*]} " == *" backend "* ]]; then
copy -d "project_name=$project_name" -d "project_slug=$project_slug" \
-d "author_name=$author_name" -d "author_email=$author_email" \
-d "serve_spa=$serve_spa" "$TEMPLATES/backend-python" "$dest"
fi
if [[ " ${parts[*]} " == *" frontend "* ]]; then
copy -d "project_name=$project_name" -d "project_slug=$project_slug" \
-d "serve=$serve" "$TEMPLATES/frontend-svelte" "$dest"
fi
if $with_caddy; then
copy -d "project_slug=$project_slug" -d "routes=$(json_list "${routes[@]}")" "$TEMPLATES/caddy" "$dest"
fi
(cd "$dest" && make env)
+9
View File
@@ -0,0 +1,9 @@
if [ -n "${ZSH_VERSION-}" ]; then
TEMPLATES_HOME="${${(%):-%x}:A:h}"
else
TEMPLATES_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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 "$@"; }
+417
View File
@@ -0,0 +1,417 @@
# /// 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
PUBLISHED = "https://git.kotikot.com/templates"
SOURCE = os.environ.get("TEMPLATES_DIR", PUBLISHED)
REF = os.environ.get("TEMPLATES_REF")
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:
local = Path(SOURCE) / name
return str(local) if local.is_dir() else f"{SOURCE}/{name}.git"
def publish_answers(dest: Path) -> None:
"""Answers rendered from local checkouts (TEMPLATES_DIR) point at Gitea.
copier writes the path it rendered from into `_src_path`; a local path
only exists on this machine, and `updateproject` anywhere else fails.
"""
local = Path(SOURCE)
if not local.is_dir():
return
for answers in (dest / ".copier").glob("*.yml"):
lines = answers.read_text(encoding="utf-8").splitlines(keepends=True)
for i, line in enumerate(lines):
if not line.startswith("_src_path:"):
continue
src = Path(line.split(":", 1)[1].strip().strip("'\""))
if src.is_dir() and src.resolve().parent == local.resolve():
lines[i] = f"_src_path: {PUBLISHED}/{src.name}.git\n"
answers.write_text("".join(lines), encoding="utf-8")
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))
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:
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 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, 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, "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_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_part("frontend", FRONTENDS[frontend], {"serve": serve})
if "caddy" in parts:
copy("caddy", {"project_slug": slug, "routes": routes})
publish_answers(dest)
if (dest / "Makefile").is_file():
subprocess.run(["make", "env"], cwd=dest, check=True)
if args.repo or (args.repo is None and interactive()):
call = gitea_api()
repo = choose_repo(dest, args.repo, call)
if repo:
setup_repo(dest, repo, call)
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")
CONFIG_FILE = Path.home() / ".config/templates/config.toml"
def config() -> dict:
import tomllib
return tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8")) if CONFIG_FILE.is_file() else {}
GITEA = os.environ.get("GITEA_URL", config().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_api():
import json
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
return call
def git(*args: str, cwd: Path) -> str:
result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
return result.stdout.strip() if result.returncode == 0 else ""
def project_description(cwd: Path) -> str:
answers = cwd / ".copier/infra.yml"
if not answers.is_file():
return ""
for line in answers.read_text(encoding="utf-8").splitlines():
if line.startswith("project_description:"):
return line.split(":", 1)[1].strip().strip("'\"")
return ""
def adopt(cwd: Path, branch: str) -> None:
"""Put a freshly rendered project on top of an existing repo.
The repo's history becomes the local one; what the templates rendered
stays in the working tree as uncommitted changes against it, files the
templates do not know are restored as they are in the repo.
"""
subprocess.run(["git", "fetch", "-q", "origin"], cwd=cwd, check=True)
# No commits yet: the branch is unborn, reset creates it at the repo's tip
# and keeps the working tree.
subprocess.run(["git", "symbolic-ref", "HEAD", f"refs/heads/{branch}"], cwd=cwd, check=True)
subprocess.run(["git", "reset", "-q", f"origin/{branch}"], cwd=cwd, check=True)
subprocess.run(["git", "branch", "-q", "-u", f"origin/{branch}"], cwd=cwd, check=True)
deleted = git("ls-files", "--deleted", cwd=cwd).splitlines()
if deleted:
subprocess.run(["git", "checkout", "-q", "--", *deleted], cwd=cwd, check=True)
print(
f"on {branch} of the existing repo: the rendered files are uncommitted changes on top of it.\n"
"Review with git status / git diff, keep what is yours (git checkout -- <file>), commit the rest."
)
def choose_repo(cwd: Path, repo: str | None, call) -> str | None:
if repo:
return repo
orgs = sorted(o["username"] for o in call("GET", "/user/orgs?limit=100") or [])
default = config().get("org") or (cwd.parent.name if cwd.parent.name in orgs else None)
if not interactive():
return f"{default}/{cwd.name}" if default else None
skip = "(no repo)"
org = questionary.select("Gitea organization", choices=[skip, *orgs], default=default or skip).unsafe_ask()
if org == skip:
return None
return f"{org}/{ask_text('Repo name', cwd.name)}"
def setup_repo(cwd: Path, repo: str, call) -> None:
import base64
import urllib.error
org, name = repo.split("/", 1)
try:
info = call("GET", f"/repos/{repo}")
print(f"repo {repo}: already there")
except urllib.error.HTTPError as error:
if error.code != 404:
sys.exit(f"repo {repo}: {error.code} {error.read()[:200]!r}")
info = call("POST", f"/orgs/{org}/repos", {
"name": name,
"description": project_description(cwd),
"private": config().get("private", True),
"default_branch": "main",
})
print(f"repo {repo}: created")
url = info["clone_url"] if config().get("remote") == "https" else info["ssh_url"]
if not info.get("empty", True) and not git("rev-parse", "--git-dir", cwd=cwd):
subprocess.run(["git", "init", "-q"], cwd=cwd, check=True)
if git("rev-parse", "--git-dir", cwd=cwd):
current = git("remote", "get-url", "origin", cwd=cwd)
if not current:
git("remote", "add", "origin", url, cwd=cwd)
print(f"origin: {url}")
elif current != url:
print(f"origin stays {current}, the repo is {url}")
if info.get("empty", True) and git("rev-parse", "--verify", "-q", "HEAD", cwd=cwd):
if not git("rev-parse", "--verify", "-q", "refs/heads/main", cwd=cwd):
git("branch", "-M", "main", cwd=cwd)
if not git("rev-parse", "--verify", "-q", "refs/heads/dev", cwd=cwd):
git("branch", "dev", "main", cwd=cwd)
subprocess.run(["git", "push", "-q", "-u", "origin", "main", "dev"], cwd=cwd, check=True)
git("switch", "-q", "dev", cwd=cwd)
print("pushed main and dev, on dev now")
elif info.get("empty", True):
print("nothing committed yet: commit, then run giteaproject again to push main and dev")
elif not git("rev-parse", "--verify", "-q", "HEAD", cwd=cwd):
adopt(cwd, info["default_branch"])
else:
print(f"{repo} is not empty and there are local commits: nothing pushed, merge them by hand")
existing = {label["name"] for label in call("GET", f"/repos/{repo}/labels?limit=100") or []}
for label, color, description in RENOVATE_LABELS:
if label in existing:
continue
call("POST", f"/repos/{repo}/labels", {"name": label, "color": color, "description": description})
print(f"label {label}: created")
remote_empty = call("GET", f"/repos/{repo}").get("empty", True)
if not remote_empty and not (cwd / RENOVATE_FILE).is_file():
try:
call("GET", f"/repos/{repo}/contents/{RENOVATE_FILE}")
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/{repo}/contents/{RENOVATE_FILE}", {
"content": base64.b64encode(RENOVATE_STUB.encode()).decode(),
"message": "chore(renovate): extend the shared preset",
})
print(f"{RENOVATE_FILE}: committed on the server, pull it")
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")
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 not in repos:
call("PUT", f"/teams/{team['id']}/repos/{org}/{name}")
print(f"team renovate: access to {name}")
print(
f"\nStill manual: add {repo} to `repositories` in\n"
"projects/personal/renovate/renovate-config.js of infra/komodo,\n"
"the bot only visits repos named in that list."
)
def gitea(args: argparse.Namespace) -> None:
"""Create the Gitea repo if needed, point origin at it and prepare it for Renovate."""
cwd = Path.cwd()
call = gitea_api()
repo = choose_repo(cwd, args.repo, call)
if repo is None:
sys.exit("no repo chosen")
setup_repo(cwd, repo, call)
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("--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("--repo", help="org/name on Gitea to create and use as origin; 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")
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 Gitea repo, set origin, prepare it for Renovate")
g.add_argument("repo", nargs="?", help="org/name on Gitea, asked when omitted")
g.set_defaults(func=gitea)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()