Compare commits
4
Commits
aa8ddc99ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c43bf7e3e | ||
|
|
b4f3a583d4 | ||
|
|
19f08ea71c | ||
|
|
095b8eacbd |
No files matched your search
@@ -1,2 +1,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
__pycache__/
|
||||||
@@ -1,49 +1,12 @@
|
|||||||
# newproject
|
# newproject
|
||||||
|
|
||||||
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/`.
|
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
|
||||||
source ~/projects/templates/newproject/shell.sh
|
source ~/projects/templates/newproject/shell.sh
|
||||||
newproject ~/projects/my-thing
|
newproject ~/projects/my-thing # asks; --parts, --backend, --frontend, --serve, --mcp, --repo, --defaults
|
||||||
updateproject # every part
|
updateproject [part] # clean tree, one commit per part
|
||||||
updateproject backend # one part
|
giteaproject [org/name] # repo in Gitea, origin, Renovate labels
|
||||||
```
|
```
|
||||||
|
|
||||||
`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:
|
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
|
|
||||||
newproject ~/projects/my-thing --parts backend,frontend,caddy --backend python --frontend svelte --serve bun --mcp context7,svelte --defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Gitea
|
|
||||||
|
|
||||||
```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. `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.
|
|
||||||
+51
-1
@@ -12,7 +12,8 @@ from pathlib import Path
|
|||||||
import questionary
|
import questionary
|
||||||
from copier import run_copy, run_update
|
from copier import run_copy, run_update
|
||||||
|
|
||||||
SOURCE = os.environ.get("TEMPLATES_DIR", "https://git.kotikot.com/templates")
|
PUBLISHED = "https://git.kotikot.com/templates"
|
||||||
|
SOURCE = os.environ.get("TEMPLATES_DIR", PUBLISHED)
|
||||||
REF = os.environ.get("TEMPLATES_REF")
|
REF = os.environ.get("TEMPLATES_REF")
|
||||||
BLANK = "blank"
|
BLANK = "blank"
|
||||||
BACKENDS = {"python": "backend-python", "other": BLANK}
|
BACKENDS = {"python": "backend-python", "other": BLANK}
|
||||||
@@ -26,6 +27,26 @@ def template(name: str) -> str:
|
|||||||
return str(local) if local.is_dir() else f"{SOURCE}/{name}.git"
|
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:
|
def git_config(key: str) -> str:
|
||||||
result = subprocess.run(["git", "config", key], capture_output=True, text=True, check=False)
|
result = subprocess.run(["git", "config", key], capture_output=True, text=True, check=False)
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
@@ -101,6 +122,7 @@ def new(args: argparse.Namespace) -> None:
|
|||||||
copy_part("frontend", FRONTENDS[frontend], {"serve": serve})
|
copy_part("frontend", FRONTENDS[frontend], {"serve": serve})
|
||||||
if "caddy" in parts:
|
if "caddy" in parts:
|
||||||
copy("caddy", {"project_slug": slug, "routes": routes})
|
copy("caddy", {"project_slug": slug, "routes": routes})
|
||||||
|
publish_answers(dest)
|
||||||
if (dest / "Makefile").is_file():
|
if (dest / "Makefile").is_file():
|
||||||
subprocess.run(["make", "env"], cwd=dest, check=True)
|
subprocess.run(["make", "env"], cwd=dest, check=True)
|
||||||
if args.repo or (args.repo is None and interactive()):
|
if args.repo or (args.repo is None and interactive()):
|
||||||
@@ -224,6 +246,28 @@ def project_description(cwd: Path) -> str:
|
|||||||
return ""
|
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:
|
def choose_repo(cwd: Path, repo: str | None, call) -> str | None:
|
||||||
if repo:
|
if repo:
|
||||||
return repo
|
return repo
|
||||||
@@ -258,6 +302,8 @@ def setup_repo(cwd: Path, repo: str, call) -> None:
|
|||||||
print(f"repo {repo}: created")
|
print(f"repo {repo}: created")
|
||||||
|
|
||||||
url = info["clone_url"] if config().get("remote") == "https" else info["ssh_url"]
|
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):
|
if git("rev-parse", "--git-dir", cwd=cwd):
|
||||||
current = git("remote", "get-url", "origin", cwd=cwd)
|
current = git("remote", "get-url", "origin", cwd=cwd)
|
||||||
if not current:
|
if not current:
|
||||||
@@ -275,6 +321,10 @@ def setup_repo(cwd: Path, repo: str, call) -> None:
|
|||||||
print("pushed main and dev, on dev now")
|
print("pushed main and dev, on dev now")
|
||||||
elif info.get("empty", True):
|
elif info.get("empty", True):
|
||||||
print("nothing committed yet: commit, then run giteaproject again to push main and dev")
|
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 []}
|
existing = {label["name"] for label in call("GET", f"/repos/{repo}/labels?limit=100") or []}
|
||||||
for label, color, description in RENOVATE_LABELS:
|
for label, color, description in RENOVATE_LABELS:
|
||||||
|
|||||||
Reference in new issue
Block a user