From 40103e9bef0a25f07bc15106e091508bccd89d50 Mon Sep 17 00:00:00 2001 From: h Date: Tue, 8 Sep 2026 20:38:27 +0200 Subject: [PATCH] Single part at the root, COMPOSE_FILE in .env --- README.md | 17 ++++--- newproject | 76 ------------------------------- shell.sh | 8 ++++ templates.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 85 deletions(-) delete mode 100755 newproject create mode 100644 shell.sh create mode 100644 templates.py diff --git a/README.md b/README.md index 4b5fe0d..6dea8f8 100644 --- a/README.md +++ b/README.md @@ -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 cloned from `https://git.kotikot.com/templates` by default; set `TEMPLATES_DIR` to a directory with local checkouts to work on the templates themselves. 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/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/`. Templates are cloned from `https://git.kotikot.com/templates` unless `TEMPLATES_DIR` points at a directory with local checkouts. -- infrastructure only: root `docker-compose.yml`, `Makefile`, `.env`, optional `caddy/` -- backend with one or many services, each `python -m ` 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. diff --git a/newproject b/newproject deleted file mode 100755 index 8927181..0000000 --- a/newproject +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -TEMPLATES="${TEMPLATES_DIR:-https://git.kotikot.com/templates}" -dest="${1:?usage: newproject [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] ]] -} - -template() { - if [[ -d "$TEMPLATES/$1" ]]; then printf '%s' "$TEMPLATES/$1"; else printf '%s' "$TEMPLATES/$1.git"; fi -} - -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[@]}")" "$(template 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" "$(template backend-python)" "$dest" -fi - -if [[ " ${parts[*]} " == *" frontend "* ]]; then - copy -d "project_name=$project_name" -d "project_slug=$project_slug" \ - -d "serve=$serve" "$(template frontend-svelte)" "$dest" -fi - -if $with_caddy; then - copy -d "project_slug=$project_slug" -d "routes=$(json_list "${routes[@]}")" "$(template caddy)" "$dest" -fi - -(cd "$dest" && make env) diff --git a/shell.sh b/shell.sh new file mode 100644 index 0000000..d2279e3 --- /dev/null +++ b/shell.sh @@ -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 "$@"; } diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..7892007 --- /dev/null +++ b/templates.py @@ -0,0 +1,126 @@ +# /// 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") +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) + + dest.mkdir(parents=True, exist_ok=True) + copy("infra", {"layout": "single" if single else "parts", "parts": [] if single else stack}) + layout = "root" if single else "part" + identity = {"project_name": name, "project_slug": slug} + 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 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") + for file in answers: + run_update(".", answers_file=str(file), unsafe=True, overwrite=True, + skip_answered=not args.ask, defaults=args.defaults) + + +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()