scaffold: pnpm-monorepo (apps/api+web, packages/shared) + docker-compose + codegen-orchestrator

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 04:24:44 +02:00
parent 170deb7b4d
commit 940e492359
11 changed files with 610 additions and 0 deletions

3
GENERATION_LOG.md Normal file
View File

@ -0,0 +1,3 @@
# EmberClone — Generation Log
Schritt-für-Schritt-Historie aller Gemma-Code-Generierungen.

View File

@ -0,0 +1,10 @@
import type { Config } from "drizzle-kit"
export default {
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL || "postgresql://emberclone:emberclone@localhost:5433/emberclone",
},
} satisfies Config

31
apps/api/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "@emberclone/api",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.1",
"argon2": "^0.40.3",
"drizzle-orm": "^0.36.0",
"fastify": "^4.28.1",
"pg": "^8.13.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/pg": "^8.11.10",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}

16
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": false
},
"include": ["src/**/*.ts"]
}

12
apps/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EmberClone</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
apps/web/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "@emberclone/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "tsc && vite build",
"preview": "vite preview --port 5174"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.62.7",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

17
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

15
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
"/api": {
target: "http://localhost:4001",
changeOrigin: true,
},
},
},
})

22
infra/docker-compose.yml Normal file
View File

@ -0,0 +1,22 @@
name: emberclone
services:
db:
image: postgres:16-alpine
container_name: emberclone-postgres
ports:
- "5433:5432"
environment:
POSTGRES_USER: emberclone
POSTGRES_PASSWORD: emberclone
POSTGRES_DB: emberclone
volumes:
- emberclone_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U emberclone"]
interval: 10s
timeout: 5s
retries: 5
volumes:
emberclone_pg_data:

View File

@ -0,0 +1,11 @@
{
"name": "@emberclone/shared",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"zod": "^3.23.8"
}
}

446
scripts/codegen.py Normal file
View File

@ -0,0 +1,446 @@
#!/usr/bin/env python3
"""
EmberClone codegen orchestrator.
Iterates through a planned list of files-to-generate, prompts Gemma for each,
verifies output (basic syntax for TS/TSX), commits to git, logs to
GENERATION_LOG.md.
Usage:
python3 scripts/codegen.py
"""
from __future__ import annotations
import asyncio
import datetime
import json
import os
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parent.parent
LOG = ROOT / "GENERATION_LOG.md"
VLLM_URL = "http://127.0.0.1:8000/v1/chat/completions"
MODEL = "gemma-4-31b"
MAX_RETRIES = 3
# --- Blueprint context (cached, loaded once) ---
BLUEPRINT_PATH = (
Path.home()
/ "AI-Memory"
/ "claude-cache"
/ "saas-blueprints"
/ "embertime"
/ "blueprint"
/ "BLUEPRINT.md"
)
@dataclass
class FileSpec:
path: str # relative to ROOT
purpose: str
deps: list[str] = field(default_factory=list) # other generated files to inline as context
extra_context: str = ""
extension: str = "" # ts/tsx/css/json — for syntax hints
# filled at runtime
attempts: int = 0
ok: bool = False
final_error: str | None = None
def log(msg: str, level: str = "INFO") -> None:
ts = datetime.datetime.now().strftime("%H:%M:%S")
LOG.parent.mkdir(exist_ok=True, parents=True)
with LOG.open("a") as f:
f.write(f"- `{ts}` **{level}** {msg}\n")
print(f"[{ts} {level}] {msg}", flush=True)
def log_section(title: str) -> None:
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with LOG.open("a") as f:
f.write(f"\n## {title} ({ts})\n\n")
print(f"\n=== {title} ===", flush=True)
def git(*args: str) -> tuple[int, str]:
result = subprocess.run(
["git", "-c", "user.email=dennis.paradzinski@it.financeflow.de",
"-c", "user.name=Dennis (via Claude+Gemma)", *args],
cwd=ROOT,
capture_output=True,
text=True,
)
return result.returncode, (result.stdout + result.stderr).strip()
async def gemma_call(prompt: str, max_tokens: int = 6000, temperature: float = 0.2) -> str | None:
"""One call to Gemma. Returns text or None on failure."""
async with httpx.AsyncClient(timeout=600) as client:
try:
r = await client.post(
VLLM_URL,
json={
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": temperature,
},
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except Exception as e:
log(f"Gemma-Call-Fehler: {type(e).__name__}: {e}", level="ERROR")
return None
def strip_codefence(text: str) -> str:
"""Remove markdown code-fences if present."""
t = text.strip()
if t.startswith("```"):
lines = t.split("\n")
if lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].startswith("```"):
lines = lines[:-1]
t = "\n".join(lines)
return t.strip()
def basic_syntax_check(path: Path, ext: str) -> tuple[bool, str]:
"""Cheap structural validation per file-type. Returns (ok, error_msg)."""
try:
content = path.read_text()
except Exception as e:
return False, f"can't read: {e}"
if not content.strip():
return False, "empty file"
if len(content) < 30:
return False, f"too short ({len(content)} chars)"
if ext == "json":
try:
json.loads(content)
return True, ""
except Exception as e:
return False, f"invalid JSON: {e}"
if ext in {"ts", "tsx"}:
if content.count("{") != content.count("}"):
return False, "unbalanced braces"
if content.count("(") != content.count(")"):
return False, "unbalanced parens"
return True, ""
def build_prompt(spec: FileSpec, blueprint_excerpt: str) -> str:
deps_text = ""
for dep in spec.deps:
dep_path = ROOT / dep
if dep_path.exists():
content = dep_path.read_text()
deps_text += f"\n\n### `{dep}` (bereits existent, referenzieren wo passend):\n```{Path(dep).suffix.lstrip('.')}\n{content[:3000]}\n```"
return f"""Du generierst eine einzelne Datei für EmberClone, eine Zeiterfassungs-Anwendung.
**EmberClone-Architektur** (pnpm-Monorepo):
- `apps/api` Fastify + Drizzle + PostgreSQL, Port :4001
- `apps/web` React + Vite + TanStack Query/Router + Tailwind, Port :5174, Vite-Proxy `/api` `:4001`
- `packages/shared` Zod-Schemas, geteilt zwischen api+web
- Auth: JWT via @fastify/jwt
- Sprache: TypeScript strict
- Code-Style: 2-space-indent, double-quotes, semicolons aus (siehe biome)
**Blueprint-Context** (aus der Analyse von Embertime):
```markdown
{blueprint_excerpt[:2500]}
```
{deps_text}
---
**Aufgabe:** Generiere die Datei `{spec.path}`.
**Zweck:** {spec.purpose}
{spec.extra_context}
**Output-Format:** ANTWORTE NUR MIT DEM DATEI-INHALT. Keine Erklärungen, keine Code-Fences (```), keine Kommentare wie "// Hier ist die Datei". Direkt der Code als wäre es schon die Datei.
"""
async def generate_file(spec: FileSpec, blueprint: str) -> bool:
log_section(f"Generiere {spec.path}")
path = ROOT / spec.path
path.parent.mkdir(parents=True, exist_ok=True)
last_error = ""
for attempt in range(MAX_RETRIES):
spec.attempts = attempt + 1
log(f"Attempt {attempt + 1}/{MAX_RETRIES} für {spec.path}")
retry_hint = ""
if attempt > 0 and last_error:
retry_hint = f"\n\n**Vorheriger Versuch fehlgeschlagen mit:** {last_error}. Bitte korrigieren."
prompt = build_prompt(spec, blueprint) + retry_hint
t0 = time.time()
response = await gemma_call(prompt)
dt = time.time() - t0
if not response:
last_error = "no response from Gemma"
log(f" no response ({dt:.1f}s)", level="WARN")
continue
content = strip_codefence(response)
path.write_text(content)
log(f" wrote {len(content)} chars in {dt:.1f}s")
ok, err = basic_syntax_check(path, spec.extension)
if ok:
log(f" syntax check ok", level="INFO")
spec.ok = True
return True
else:
last_error = err
log(f" syntax check failed: {err}", level="WARN")
spec.final_error = last_error
log(f" GAVE UP after {MAX_RETRIES} attempts: {last_error}", level="ERROR")
return False
# ---- File specs in dependency order ----
SPECS: list[FileSpec] = [
FileSpec(
path="packages/shared/src/schemas.ts",
purpose=(
"Zod-Schemas für die Entitäten User, TimeEntry, Project, Customer. "
"Pro Entität: ein Insert-Schema + Select-Schema (mit ID, timestamps). "
"Plus Login-Request-Schema (email + password). "
"Exportiere alle Schemas + die zugehörigen TypeScript-Types via z.infer<typeof X>."
),
extension="ts",
extra_context=(
"Felder: User(id,email,name,role:'admin'|'user',passwordHash,createdAt). "
"TimeEntry(id,userId,projectId?,description,startTime,endTime?,createdAt). "
"Project(id,name,customerId,active,createdAt). "
"Customer(id,name,active,createdAt)."
),
),
FileSpec(
path="packages/shared/src/index.ts",
purpose="Re-export aller Schemas und Types aus schemas.ts via `export * from './schemas'`.",
deps=["packages/shared/src/schemas.ts"],
extension="ts",
),
FileSpec(
path="apps/api/src/db/schema.ts",
purpose=(
"Drizzle-ORM Postgres-Schema für die Tabellen users, time_entries, projects, customers. "
"Spalten matchen die Felder aus packages/shared/src/schemas.ts. "
"Verwende pgTable, uuid().primaryKey().defaultRandom(), timestamp().notNull().defaultNow(), text().notNull(), boolean(). "
"Foreign Keys mit references() und onDelete: 'cascade' wo passend."
),
deps=["packages/shared/src/schemas.ts"],
extension="ts",
),
FileSpec(
path="apps/api/src/db/index.ts",
purpose=(
"Drizzle-DB-Connection aus DATABASE_URL env var (default postgresql://emberclone:emberclone@localhost:5433/emberclone). "
"Exportiert: `db` (drizzle-Instanz), `pool` (pg.Pool), und das schema."
),
deps=["apps/api/src/db/schema.ts"],
extension="ts",
),
FileSpec(
path="apps/api/src/db/migrate.ts",
purpose=(
"Migration-Runner-Script. Importiert die Drizzle-Instanz und ruft `await migrate(db, { migrationsFolder: './drizzle' })`. "
"Anschließend: prüft ob ein Admin-User existiert, wenn nicht, legt einen Default-Admin an: email='admin@emberclone.local', password='emberclone2026', "
"name='Admin', role='admin'. Passwort mit argon2 hashen. Logged Erfolg/Fehler und exit(0)."
),
deps=["apps/api/src/db/index.ts", "apps/api/src/db/schema.ts"],
extension="ts",
),
FileSpec(
path="apps/api/src/routes/auth.ts",
purpose=(
"Fastify-Plugin für Auth-Routes: POST /api/auth/login (email+password → JWT in cookie), "
"GET /api/auth/me (returns current user from JWT), POST /api/auth/logout (clear cookie). "
"Verwende @fastify/jwt. Passwort-Check mit argon2.verify. "
"Zod-Validierung des Request-Body via @emberclone/shared LoginRequestSchema."
),
deps=["apps/api/src/db/index.ts", "apps/api/src/db/schema.ts"],
extension="ts",
),
FileSpec(
path="apps/api/src/routes/time-entries.ts",
purpose=(
"Fastify-Plugin für CRUD /api/time-entries. "
"GET / (list, filter by date range via query ?from=...&to=...), "
"GET /:id (single), POST / (create), PATCH /:id (update), DELETE /:id. "
"Alle Routes brauchen Auth (request.jwtVerify()). userId aus JWT-Payload. "
"User sieht nur eigene Einträge ausser admin."
),
deps=["apps/api/src/db/index.ts", "apps/api/src/db/schema.ts", "apps/api/src/routes/auth.ts"],
extension="ts",
),
FileSpec(
path="apps/api/src/routes/index.ts",
purpose=(
"Registriert alle Route-Plugins beim Fastify-Server. Importiert authRoutes und timeEntriesRoutes "
"und registriert sie. Export: setupRoutes(server: FastifyInstance)."
),
deps=["apps/api/src/routes/auth.ts", "apps/api/src/routes/time-entries.ts"],
extension="ts",
),
FileSpec(
path="apps/api/src/index.ts",
purpose=(
"Fastify-Server-Entry. PORT=4001 oder env.PORT. HOST=0.0.0.0. "
"Plugins: @fastify/cors (origin: http://localhost:5174, credentials: true), "
"@fastify/cookie, @fastify/jwt (secret aus env JWT_SECRET, fallback 'dev-secret-change-me', cookie: {cookieName: 'token', signed: false}). "
"GET /health → {status: 'ok'}. Dann setupRoutes(server). server.listen()."
),
deps=["apps/api/src/routes/index.ts"],
extension="ts",
),
FileSpec(
path="apps/web/src/main.tsx",
purpose=(
"React-Entry. Mountet App in #root. Wrappt mit QueryClientProvider (TanStack Query) und RouterProvider (TanStack Router). "
"Importiert ./index.css."
),
extension="tsx",
),
FileSpec(
path="apps/web/src/lib/api.ts",
purpose=(
"Fetch-Wrapper für die API. Base-URL '/api' (Vite-Proxy auf :4001). "
"Funktionen: login(email, password), logout(), getMe(), "
"listTimeEntries(opts?), createTimeEntry(data), deleteTimeEntry(id). "
"Alle Requests mit credentials: 'include'."
),
extension="ts",
),
FileSpec(
path="apps/web/src/pages/Login.tsx",
purpose=(
"Login-Page mit Email- und Passwort-Form. Bei Erfolg → navigate('/'). "
"Tailwind-Styles, minimalistisch (max-w-md, card, primary-button). "
"Default-Werte vorgefüllt: email='admin@emberclone.local', password='emberclone2026' für Dev-Convenience."
),
deps=["apps/web/src/lib/api.ts"],
extension="tsx",
),
FileSpec(
path="apps/web/src/pages/Dashboard.tsx",
purpose=(
"Dashboard mit Welcome-Card ('Hallo {user.name}'), Übersicht über Anzahl heutiger Zeit-Einträge. "
"Liest user via getMe() und entries via listTimeEntries(). Logout-Button oben rechts. "
"Tailwind, einfaches Grid-Layout."
),
deps=["apps/web/src/lib/api.ts"],
extension="tsx",
),
FileSpec(
path="apps/web/src/pages/TimeEntries.tsx",
purpose=(
"TimeEntries-Page mit Liste aller Einträge des aktuellen Users (TanStack Query) "
"und Inline-Form zum Erstellen eines neuen Eintrags (description, startTime, endTime). "
"Pro Eintrag: Delete-Button. Tailwind-Tabelle."
),
deps=["apps/web/src/lib/api.ts"],
extension="tsx",
),
FileSpec(
path="apps/web/src/App.tsx",
purpose=(
"Router-Setup mit TanStack Router. Routes: / (Dashboard), /login (Login), /time-entries (TimeEntries). "
"Geschützte Routes: bei 401 redirect zu /login. Verwende `createRouter` + `createRootRoute`."
),
deps=["apps/web/src/pages/Login.tsx", "apps/web/src/pages/Dashboard.tsx", "apps/web/src/pages/TimeEntries.tsx"],
extension="tsx",
),
FileSpec(
path="apps/web/src/index.css",
purpose=(
"Tailwind-Entry-CSS: @tailwind base; @tailwind components; @tailwind utilities; "
"Plus minimaler Reset für body (bg-slate-50, font-sans)."
),
extension="css",
),
FileSpec(
path="apps/web/postcss.config.cjs",
purpose="PostCSS-Config: tailwindcss + autoprefixer als Plugins (CommonJS-Format wegen .cjs).",
extension="js",
),
FileSpec(
path="apps/web/tailwind.config.ts",
purpose=(
"Tailwind-Config v3 mit content: ['./index.html', './src/**/*.{ts,tsx}']. "
"Plus theme.extend.colors mit ember: {500: '#f97316', 600: '#ea580c'} als Brand-Color."
),
extension="ts",
),
]
async def main() -> int:
log_section("EmberClone Codegen-Run gestartet")
log(f"Specs: {len(SPECS)} Files zu generieren")
log(f"vLLM: {VLLM_URL}, Model: {MODEL}")
if not BLUEPRINT_PATH.exists():
log(f"Blueprint not found at {BLUEPRINT_PATH}", level="ERROR")
return 1
blueprint = BLUEPRINT_PATH.read_text()
# quick gemma ping
log("Pinging Gemma …")
pong = await gemma_call('Sag "pong" und sonst nichts.', max_tokens=10)
if not pong or "pong" not in pong.lower():
log(f"Gemma ping failed: {pong!r}", level="ERROR")
return 2
log(f"Gemma pong ok: {pong.strip()!r}")
ok_count = 0
fail_count = 0
for spec in SPECS:
success = await generate_file(spec, blueprint)
if success:
ok_count += 1
# commit per file
rc, _ = git("add", spec.path)
rc, _ = git("commit", "-q", "-m", f"gemma: generate {spec.path}")
else:
fail_count += 1
# commit anyway so user sees attempt
rc, _ = git("add", spec.path)
rc, _ = git("commit", "-q", "-m", f"gemma: ATTEMPT {spec.path} (failed validation: {spec.final_error})")
log_section("Codegen-Run beendet")
log(f"ok: {ok_count}/{len(SPECS)}, fail: {fail_count}/{len(SPECS)}")
if fail_count == 0:
log("Alle Files generiert. Triggering Build-Test.")
return 0
else:
log(f"{fail_count} Files mit final-Fehler. Manuelle Inspektion nötig.", level="WARN")
return 0 if ok_count > 0 else 3
if __name__ == "__main__":
sys.exit(asyncio.run(main()))