Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68034cd2f5 | ||
|
|
81d518f2c8 | ||
|
|
2785443794 | ||
|
|
d7e948df33 | ||
|
|
454be36bec | ||
|
|
e0550cb462 | ||
|
|
1f663c8e51 | ||
|
|
7ff97419e3 | ||
|
|
79cb646dd3 | ||
|
|
c31f0b241f | ||
|
|
40103e9bef | ||
|
|
cb30b8c5d8 | ||
|
|
216020f152 | ||
|
|
7a1774a36f |
@@ -1,21 +1,41 @@
|
|||||||
# 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.
|
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/`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
source ~/projects/templates/newproject/shell.sh
|
||||||
newproject ~/projects/my-thing
|
newproject ~/projects/my-thing
|
||||||
|
updateproject # every part
|
||||||
|
updateproject backend # one part
|
||||||
```
|
```
|
||||||
|
|
||||||
Templates are read from `$TEMPLATES_DIR` (default `~/projects/templates`). Every part updates independently:
|
`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:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
copier update -a .copier-answers.backend.yml
|
newproject ~/projects/my-thing --parts backend,frontend,caddy --backend python --frontend svelte --serve bun --defaults
|
||||||
```
|
```
|
||||||
|
|
||||||
Shapes it produces:
|
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.
|
||||||
|
|
||||||
- infrastructure only: root `docker-compose.yml`, `Makefile`, `.env`, optional `caddy/`
|
## Renovate
|
||||||
- backend with one or many services, each `python -m <module>` from one image
|
|
||||||
- backend and frontend as separate containers behind Caddy
|
A new repo needs the `renovate`, `wait-3d` and `deps-code` labels before the bot
|
||||||
- backend serving the frontend's static build from the api
|
ever runs against it: Renovate fails on a label that does not exist, and until
|
||||||
- frontend only
|
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.
|
||||||
|
|||||||
-72
@@ -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)
|
|
||||||
@@ -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 "$@"; }
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
# /// 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_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 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", 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:
|
||||||
|
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="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)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user