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()))