9 Commits
Author SHA1 Message Date
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
4 changed files with 154 additions and 81 deletions
+8 -9
View File
@@ -1,21 +1,20 @@
# 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
source ~/projects/templates/newproject/shell.sh
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
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/`
- 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
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)
+8
View File
@@ -0,0 +1,8 @@
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 "$@"; }
+138
View File
@@ -0,0 +1,138 @@
# /// 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")
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)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()