#!/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()))