21 Commits
Author SHA1 Message Date
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 464 additions and 81 deletions

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
.DS_Store .DS_Store
.idea .idea
__pycache__/
+37 -9
View File
@@ -1,21 +1,49 @@
# 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`, `blank` 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 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, and finally offers to create the Gitea repo. 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 --mcp context7,svelte --defaults
``` ```
Shapes it produces: `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.
- infrastructure only: root `docker-compose.yml`, `Makefile`, `.env`, optional `caddy/` 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, refreshing `uv.lock` and `bun.lock` first, so the next part starts from a clean tree again. When copier leaves conflicts it stops before committing and lists the files: resolve, commit, run it again. 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. Either way the answers in `.copier/` point at Gitea, not at the local checkout, so `updateproject` works on any machine; it always updates from Gitea, so push a template change before updating projects with it.
- backend with one or many services, each `python -m <module>` from one image
- backend and frontend as separate containers behind Caddy ## Gitea
- backend serving the frontend's static build from the api
- frontend only ```sh
giteaproject # asks for the organization and the name
giteaproject ms-agents/my-thing
```
Run in the project directory. It creates the repo if it does not exist (private, `main` as the default branch, the description from `.copier/infra.yml`), adds it as `origin` over ssh, and when the repo is still empty and there are local commits, pushes `main` and `dev` and switches to `dev`. An existing `origin` that points elsewhere is left alone. When the repo already has commits and the project has none yet (a fresh `newproject` for code that lives in Gitea already), the project lands on top of it: the repo's history becomes the local one, the rendered files stay as uncommitted changes against it, and files the templates do not render come back as they are in the repo. Review with `git status` and `git diff`, keep what is yours, commit the rest. `newproject` asks for the organization at the end, or takes `--repo org/name`; without a terminal and without `--repo` it creates nothing.
The organization defaults to `org` from the config, else to the name of the parent directory when it matches an organization. `~/.config/templates/config.toml`:
```toml
gitea_url = "https://git.kotikot.com"
remote = "ssh"
private = true
org = "ms-agents"
```
The token comes from `GITEA_TOKEN` or `~/.config/gitea/token`.
The same command prepares the repo for Renovate: the `renovate`, `wait-3d` and `deps-code` labels (Renovate fails on a label that does not exist), write access for the organization's `renovate` team, and, for an existing repo without one, a `renovate.jsonc` committed on the server that extends the shared preset in `templates/renovate`. Projects rendered by `newproject` carry that file from the `infra` template already.
**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`. 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
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()