From 940e492359a2c739022d9a5a2ef9bb2595fd9f67 Mon Sep 17 00:00:00 2001 From: "Dennis (via Claude+Gemma)" Date: Sat, 23 May 2026 04:24:44 +0200 Subject: [PATCH] scaffold: pnpm-monorepo (apps/api+web, packages/shared) + docker-compose + codegen-orchestrator --- GENERATION_LOG.md | 3 + apps/api/drizzle.config.ts | 10 + apps/api/package.json | 31 +++ apps/api/tsconfig.json | 16 ++ apps/web/index.html | 12 + apps/web/package.json | 27 +++ apps/web/tsconfig.json | 17 ++ apps/web/vite.config.ts | 15 ++ infra/docker-compose.yml | 22 ++ packages/shared/package.json | 11 + scripts/codegen.py | 446 +++++++++++++++++++++++++++++++++++ 11 files changed, 610 insertions(+) create mode 100644 GENERATION_LOG.md create mode 100644 apps/api/drizzle.config.ts create mode 100644 apps/api/package.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 infra/docker-compose.yml create mode 100644 packages/shared/package.json create mode 100644 scripts/codegen.py diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md new file mode 100644 index 0000000..c87e48f --- /dev/null +++ b/GENERATION_LOG.md @@ -0,0 +1,3 @@ +# EmberClone — Generation Log + +Schritt-für-Schritt-Historie aller Gemma-Code-Generierungen. diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..d931133 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..0999588 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..883fd98 --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..153710d --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + EmberClone + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..8bfbd1d --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..b840fac --- /dev/null +++ b/apps/web/tsconfig.json @@ -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"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..9167173 --- /dev/null +++ b/apps/web/vite.config.ts @@ -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, + }, + }, + }, +}) diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..c08e165 --- /dev/null +++ b/infra/docker-compose.yml @@ -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: diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..5afefd9 --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/scripts/codegen.py b/scripts/codegen.py new file mode 100644 index 0000000..2a1e04e --- /dev/null +++ b/scripts/codegen.py @@ -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." + ), + 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()))