| |
| """Code project harness for small business-owner app builds. |
| |
| This harness creates a real multi-file project instead of a one-file demo. It |
| keeps the model out of fragile boilerplate and makes the product path return a |
| project with files, tests, a change summary, and deterministic validation. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import difflib |
| import json |
| import re |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| @dataclass |
| class CodeProjectSpec: |
| project_name: str |
| project_type: str |
| description: str |
| features: list[str] = field(default_factory=list) |
| entities: list[str] = field(default_factory=list) |
| fields: list[str] = field(default_factory=list) |
| commands: list[str] = field(default_factory=list) |
|
|
|
|
| PROJECT_DEFAULTS: dict[str, dict[str, list[str] | str]] = { |
| "stripe_checkout": { |
| "description": "Stripe-ready checkout starter with server-side session creation and webhook notes.", |
| "features": ["Pricing cards", "Checkout session route", "Webhook verification notes", "Success and cancel states"], |
| "entities": ["product", "checkout_session", "customer"], |
| "fields": ["Product", "Price", "Billing email", "Status"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "booking_app": { |
| "description": "Appointment booking starter with customer/service/date capture and local persistence.", |
| "features": ["Booking form", "Appointment list", "Status updates", "CSV export"], |
| "entities": ["appointment", "customer", "service"], |
| "fields": ["Customer", "Service", "Date", "Time", "Status"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "crm_app": { |
| "description": "Lead tracker starter for small businesses that need follow-up discipline.", |
| "features": ["Lead form", "Pipeline status", "Follow-up date", "CSV export"], |
| "entities": ["lead", "company", "follow_up"], |
| "fields": ["Name", "Company", "Need", "Status", "Next Follow-up"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "dashboard": { |
| "description": "Small business dashboard starter with metrics, tasks, and next actions.", |
| "features": ["KPI cards", "Task list", "Recent activity", "Next action panel"], |
| "entities": ["metric", "task", "activity"], |
| "fields": ["Metric", "Value", "Owner", "Status"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "invoice_app": { |
| "description": "Invoice builder starter for small businesses that need fast billing and payment tracking.", |
| "features": ["Invoice form", "Client and service tracking", "Payment status", "CSV export"], |
| "entities": ["invoice", "client", "service"], |
| "fields": ["Client", "Service", "Amount", "Due Date", "Status"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "estimate_app": { |
| "description": "Estimate builder starter for service businesses that need clear quotes and follow-up tracking.", |
| "features": ["Estimate form", "Scope and price tracking", "Follow-up date", "CSV export"], |
| "entities": ["estimate", "client", "job"], |
| "fields": ["Client", "Job", "Scope", "Price", "Follow-up Date", "Status"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "content_calendar": { |
| "description": "Content calendar starter for creators and solo founders planning posts, hooks, and publishing dates.", |
| "features": ["Content idea capture", "Channel planning", "Publish date tracking", "CSV export"], |
| "entities": ["content_item", "channel", "campaign"], |
| "fields": ["Idea", "Channel", "Hook", "Publish Date", "Status"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "expense_tracker": { |
| "description": "Expense tracker starter for owners who need fast cash and vendor visibility.", |
| "features": ["Expense form", "Vendor and category tracking", "Payment method tracking", "CSV export"], |
| "entities": ["expense", "vendor", "category"], |
| "fields": ["Vendor", "Category", "Amount", "Payment Method", "Date", "Status"], |
| "commands": ["npm install", "npm run lint", "npm run test"], |
| }, |
| "cloudflare_worker": { |
| "description": "Cloudflare Worker API starter with validation, CORS, health check, tests, and safe environment handling.", |
| "features": ["Health check", "Validated JSON endpoint", "CORS handling", "Worker tests", "Wrangler deployment notes"], |
| "entities": ["request", "lead", "response"], |
| "fields": ["Name", "Email", "Need", "Source"], |
| "commands": ["npm install", "npm run dev", "npm run build", "npm run test", "npm run deploy"], |
| }, |
| } |
|
|
|
|
| def clean_text(value: Any, fallback: str) -> str: |
| if not isinstance(value, str): |
| return fallback |
| value = re.sub(r"\s+", " ", value).strip() |
| return value or fallback |
|
|
|
|
| def slugify(value: str) -> str: |
| return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "kaiju-project" |
|
|
|
|
| def pascal_case(value: str) -> str: |
| words = re.findall(r"[A-Za-z0-9]+", value) |
| return "".join(word[:1].upper() + word[1:] for word in words) or "KaijuProject" |
|
|
|
|
| def infer_project_type(prompt: str) -> str: |
| lower = prompt.lower() |
| if "content calendar" in lower or "content planner" in lower or "posting calendar" in lower: |
| return "content_calendar" |
| if "expense tracker" in lower or "budget tracker" in lower or "cash tracker" in lower: |
| return "expense_tracker" |
| if "estimate app" in lower or "estimate builder" in lower or "quote app" in lower or "quote builder" in lower: |
| return "estimate_app" |
| if "invoice app" in lower or "invoice builder" in lower or "invoice tracker" in lower or "invoice generator" in lower: |
| return "invoice_app" |
| if "stripe" in lower or "checkout" in lower or "payment" in lower: |
| return "stripe_checkout" |
| if any(term in lower for term in ["cloudflare worker", "worker api", "wrangler", "d1", "r2", "durable object", "api worker", "telegram", "webhook", "file upload", "search proxy", "perplexity", "rate limit", "license check", "api key"]): |
| return "cloudflare_worker" |
| if "booking" in lower or "appointment" in lower or "schedule" in lower: |
| return "booking_app" |
| if "crm" in lower or "lead" in lower or "pipeline" in lower or "prospect" in lower: |
| return "crm_app" |
| return "dashboard" |
|
|
|
|
| def infer_project_name(prompt: str, project_type: str) -> str: |
| stop_words = r"\s+(?:for|with|to|that|using|include|including|built|as)\b" |
| for marker in ["named", "called"]: |
| match = re.search(rf"{marker}\s+([A-Z][A-Za-z0-9 &'&-]{{2,70}}?)(?:\.|,|{stop_words}|$)", prompt, flags=re.IGNORECASE) |
| if match: |
| return clean_text(match.group(1), "Kaiju Project").rstrip(".") |
| names = { |
| "stripe_checkout": "Checkout Starter", |
| "booking_app": "Booking Desk", |
| "crm_app": "Pipeline Desk", |
| "dashboard": "Operator Dashboard", |
| "invoice_app": "Invoice Desk", |
| "estimate_app": "Estimate Desk", |
| "content_calendar": "Content Desk", |
| "expense_tracker": "Expense Desk", |
| "cloudflare_worker": "Worker API", |
| } |
| return names[project_type] |
|
|
|
|
| def spec_from_prompt(prompt: str) -> CodeProjectSpec: |
| project_type = infer_project_type(prompt) |
| defaults = PROJECT_DEFAULTS[project_type] |
| features = list(defaults["features"]) |
| entities = list(defaults["entities"]) |
| fields = list(defaults["fields"]) |
| lower = prompt.lower() |
| if project_type == "cloudflare_worker": |
| if "telegram" in lower: |
| features.append("Telegram webhook route") |
| entities.append("telegram_update") |
| if "r2" in lower or "upload" in lower or "file" in lower: |
| features.append("R2 file upload route") |
| entities.append("uploaded_file") |
| if "d1" in lower or "database" in lower or "persist" in lower or "store" in lower: |
| features.append("D1 persistence notes") |
| entities.append("database_record") |
| if "search" in lower or "perplexity" in lower: |
| features.append("Server-side search proxy") |
| entities.append("search_query") |
| if "rate limit" in lower or "license" in lower or "api key" in lower or "auth" in lower: |
| features.append("Bearer auth and rate limit guard") |
| entities.append("api_client") |
| return CodeProjectSpec( |
| project_name=infer_project_name(prompt, project_type), |
| project_type=project_type, |
| description=str(defaults["description"]), |
| features=list(dict.fromkeys(features)), |
| entities=list(dict.fromkeys(entities)), |
| fields=fields, |
| commands=list(defaults["commands"]), |
| ) |
|
|
|
|
| def normalize_spec(raw: dict[str, Any] | CodeProjectSpec, prompt: str = "") -> CodeProjectSpec: |
| if isinstance(raw, CodeProjectSpec): |
| spec = raw |
| else: |
| fallback = spec_from_prompt(prompt) |
| features = raw.get("features") if isinstance(raw.get("features"), list) else fallback.features |
| entities = raw.get("entities") if isinstance(raw.get("entities"), list) else fallback.entities |
| fields = raw.get("fields") if isinstance(raw.get("fields"), list) else fallback.fields |
| commands = raw.get("commands") if isinstance(raw.get("commands"), list) else fallback.commands |
| spec = CodeProjectSpec( |
| project_name=clean_text(raw.get("project_name"), fallback.project_name), |
| project_type=clean_text(raw.get("project_type"), fallback.project_type).lower(), |
| description=clean_text(raw.get("description"), fallback.description), |
| features=[clean_text(item, "") for item in features if isinstance(item, str)][:8] or fallback.features, |
| entities=[clean_text(item, "") for item in entities if isinstance(item, str)][:6] or fallback.entities, |
| fields=[clean_text(item, "") for item in fields if isinstance(item, str)][:8] or fallback.fields, |
| commands=[clean_text(item, "") for item in commands if isinstance(item, str)][:5] or fallback.commands, |
| ) |
| if spec.project_type not in PROJECT_DEFAULTS: |
| spec.project_type = infer_project_type(prompt) |
| if len(spec.features) < 3: |
| spec.features = list(PROJECT_DEFAULTS[spec.project_type]["features"]) |
| if len(spec.fields) < 3: |
| spec.fields = list(PROJECT_DEFAULTS[spec.project_type]["fields"]) |
| return spec |
|
|
|
|
| def render_package_json(spec: CodeProjectSpec) -> str: |
| if spec.project_type == "cloudflare_worker": |
| package = { |
| "name": slugify(spec.project_name), |
| "version": "0.1.0", |
| "private": True, |
| "type": "module", |
| "scripts": { |
| "dev": "wrangler dev", |
| "build": "wrangler deploy --dry-run", |
| "deploy": "wrangler deploy", |
| "test": "vitest run", |
| "lint": "tsc --noEmit", |
| }, |
| "dependencies": { |
| "zod": "^3.23.8", |
| }, |
| "devDependencies": { |
| "@cloudflare/workers-types": "^4.20250501.0", |
| "typescript": "^5.6.0", |
| "vitest": "^2.1.0", |
| "wrangler": "^4.0.0", |
| }, |
| } |
| return json.dumps(package, indent=2) + "\n" |
|
|
| package = { |
| "name": slugify(spec.project_name), |
| "version": "0.1.0", |
| "private": True, |
| "scripts": { |
| "dev": "next dev", |
| "build": "next build", |
| "lint": "tsc --noEmit", |
| "test": "vitest run", |
| }, |
| "dependencies": { |
| "next": "^15.0.0", |
| "react": "^19.0.0", |
| "react-dom": "^19.0.0", |
| "zod": "^3.23.8", |
| }, |
| "devDependencies": { |
| "@types/node": "^22.0.0", |
| "@types/react": "^19.0.0", |
| "@types/react-dom": "^19.0.0", |
| "typescript": "^5.6.0", |
| "vitest": "^2.1.0", |
| }, |
| } |
| if spec.project_type == "stripe_checkout": |
| package["dependencies"]["stripe"] = "^17.0.0" |
| return json.dumps(package, indent=2) + "\n" |
|
|
|
|
| def render_tsconfig() -> str: |
| return """{ |
| "compilerOptions": { |
| "target": "ES2022", |
| "lib": ["dom", "dom.iterable", "es2022"], |
| "allowJs": false, |
| "skipLibCheck": true, |
| "strict": true, |
| "noEmit": true, |
| "esModuleInterop": true, |
| "module": "esnext", |
| "moduleResolution": "bundler", |
| "resolveJsonModule": true, |
| "isolatedModules": true, |
| "jsx": "preserve", |
| "incremental": true, |
| "plugins": [{ "name": "next" }] |
| }, |
| "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], |
| "exclude": ["node_modules"] |
| } |
| """ |
|
|
|
|
| def render_next_config() -> str: |
| return """/** @type {import('next').NextConfig} */ |
| const nextConfig = { |
| reactStrictMode: true |
| }; |
| |
| module.exports = nextConfig; |
| """ |
|
|
|
|
| def render_worker_tsconfig() -> str: |
| return """{ |
| "compilerOptions": { |
| "target": "ES2022", |
| "module": "ESNext", |
| "moduleResolution": "Bundler", |
| "lib": ["ES2022"], |
| "types": ["@cloudflare/workers-types"], |
| "strict": true, |
| "skipLibCheck": true, |
| "noEmit": true |
| }, |
| "include": ["src/**/*.ts", "tests/**/*.ts"] |
| } |
| """ |
|
|
|
|
| def worker_has(spec: CodeProjectSpec, *terms: str) -> bool: |
| text = " ".join([spec.project_name, spec.description, *spec.features, *spec.entities, *spec.fields]).lower() |
| return any(term.lower() in text for term in terms) |
|
|
|
|
| def render_wrangler_toml(spec: CodeProjectSpec) -> str: |
| bindings: list[str] = [] |
| if worker_has(spec, "d1", "database", "persist"): |
| bindings.append(""" |
| [[d1_databases]] |
| binding = "DB" |
| database_name = "kaiju_dev" |
| database_id = "replace-with-d1-database-id" |
| """) |
| if worker_has(spec, "r2", "upload", "file"): |
| bindings.append(""" |
| [[r2_buckets]] |
| binding = "FILES_BUCKET" |
| bucket_name = "kaiju-dev-files" |
| """) |
| return f"""name = "{slugify(spec.project_name)}" |
| main = "src/index.ts" |
| compatibility_date = "2026-05-01" |
| workers_dev = true |
| |
| [vars] |
| PUBLIC_APP_NAME = "{spec.project_name}" |
| """ + "".join(bindings) |
|
|
|
|
| def render_css() -> str: |
| return """* { box-sizing: border-box; } |
| body { |
| margin: 0; |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| background: radial-gradient(circle at top right, rgba(244, 173, 50, 0.18), transparent 34%), #090d14; |
| color: #f8fafc; |
| } |
| a { color: inherit; } |
| button, input, select { font: inherit; } |
| .shell { max-width: 1120px; margin: 0 auto; padding: 42px 24px; } |
| .eyebrow { color: #f4ad32; text-transform: uppercase; letter-spacing: .16em; font-size: 12px; font-weight: 900; } |
| .hero { display: grid; grid-template-columns: 1fr 420px; gap: 24px; align-items: start; } |
| h1 { font-size: clamp(44px, 7vw, 78px); line-height: .95; letter-spacing: -.055em; margin: 12px 0; } |
| p { color: #a6b0c2; line-height: 1.65; font-size: 18px; } |
| .panel { background: rgba(255,255,255,.045); border: 1px solid rgba(148,163,184,.22); border-radius: 26px; padding: 22px; box-shadow: 0 24px 80px rgba(0,0,0,.22); } |
| .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 22px; } |
| .card { background: rgba(255,255,255,.045); border: 1px solid rgba(148,163,184,.18); border-radius: 20px; padding: 18px; } |
| .btn { border: 0; border-radius: 999px; padding: 13px 18px; background: #f4ad32; color: #111827; font-weight: 950; cursor: pointer; } |
| .field { display: block; margin-bottom: 12px; color: #a6b0c2; font-weight: 800; } |
| .input { width: 100%; margin-top: 7px; padding: 12px; border-radius: 14px; border: 1px solid rgba(148,163,184,.24); background: #0e1420; color: #f8fafc; } |
| .list { margin: 0; padding-left: 18px; color: #dbe3f0; line-height: 1.75; } |
| .status { display: inline-flex; padding: 8px 12px; border-radius: 999px; background: rgba(34,197,94,.14); color: #86efac; font-weight: 900; } |
| @media (max-width: 880px) { |
| .hero, .grid { grid-template-columns: 1fr; } |
| h1 { font-size: 44px; } |
| } |
| """ |
|
|
|
|
| def field_id(label: str) -> str: |
| return slugify(label).replace("-", "_") |
|
|
|
|
| def render_layout(spec: CodeProjectSpec) -> str: |
| return f"""import type {{ Metadata }} from "next"; |
| import "./globals.css"; |
| |
| export const metadata: Metadata = {{ |
| title: "{spec.project_name}", |
| description: "{spec.description}", |
| }}; |
| |
| export default function RootLayout({{ children }}: {{ children: React.ReactNode }}) {{ |
| return ( |
| <html lang="en"> |
| <body>{{children}}</body> |
| </html> |
| ); |
| }} |
| """ |
|
|
|
|
| def render_page(spec: CodeProjectSpec) -> str: |
| component = pascal_case(spec.project_name) |
| feature_cards = "\n".join(f' <article className="card"><h3>{feature}</h3><p>Built into the first version so the owner can use it immediately.</p></article>' for feature in spec.features[:6]) |
| fields = "\n".join( |
| f' <label className="field">{label}<input className="input" name="{field_id(label)}" placeholder="{label}" /></label>' |
| for label in spec.fields[:6] |
| ) |
| if spec.project_type == "stripe_checkout": |
| side_panel = """ <form action="/api/checkout" method="POST" className="panel"> |
| <h2>Checkout test</h2> |
| <p>Server-side route creates the Checkout Session. Use environment variables, never client-side secrets.</p> |
| <input type="hidden" name="priceId" value="price_replace_me" /> |
| <button className="btn" type="submit">Start checkout</button> |
| </form>""" |
| else: |
| side_panel = f""" <form className="panel"> |
| <h2>Add record</h2> |
| {fields} |
| <button className="btn" type="button">Save locally</button> |
| </form>""" |
| return f"""const features = {json.dumps(spec.features[:6], indent=2)}; |
| |
| export default function {component}Page() {{ |
| return ( |
| <main className="shell"> |
| <section className="hero"> |
| <div> |
| <p className="eyebrow">Kaiju project harness</p> |
| <h1>{spec.project_name}</h1> |
| <p>{spec.description}</p> |
| <span className="status">{spec.project_type.replace("_", " ")}</span> |
| </div> |
| {side_panel} |
| </section> |
| |
| <section className="grid" aria-label="Project features"> |
| {feature_cards} |
| </section> |
| |
| <section className="panel" style={{{{ marginTop: 22 }}}}> |
| <h2>Implementation notes</h2> |
| <ul className="list"> |
| {{features.map((feature) => <li key={{feature}}>{{feature}}</li>)}} |
| </ul> |
| </section> |
| </main> |
| ); |
| }} |
| """ |
|
|
|
|
| def render_interactive_page(spec: CodeProjectSpec) -> str: |
| component = pascal_case(spec.project_name) |
| fields = [{"id": field_id(label), "label": label} for label in spec.fields[:6]] |
| field_labels = ", ".join(field["label"] for field in fields) |
| return f""""use client"; |
| |
| import type {{ FormEvent }} from "react"; |
| import {{ useEffect, useState }} from "react"; |
| import {{ toCsv, type CsvRow }} from "../lib/csv"; |
| |
| type RecordItem = {{ |
| id: string; |
| createdAt: string; |
| }} & Record<string, string>; |
| |
| const fields = {json.dumps(fields, indent=2)}; |
| const storageKey = "kaiju:{slugify(spec.project_name)}"; |
| |
| function emptyDraft(): Record<string, string> {{ |
| return Object.fromEntries(fields.map((field) => [field.id, ""])) as Record<string, string>; |
| }} |
| |
| function downloadText(filename: string, content: string): void {{ |
| const blob = new Blob([content], {{ type: "text/csv;charset=utf-8" }}); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement("a"); |
| link.href = url; |
| link.download = filename; |
| link.click(); |
| URL.revokeObjectURL(url); |
| }} |
| |
| export default function {component}Page() {{ |
| const [records, setRecords] = useState<RecordItem[]>([]); |
| const [draft, setDraft] = useState<Record<string, string>>(emptyDraft); |
| |
| useEffect(() => {{ |
| const saved = window.localStorage.getItem(storageKey); |
| if (!saved) return; |
| try {{ |
| const parsed = JSON.parse(saved) as RecordItem[]; |
| if (Array.isArray(parsed)) setRecords(parsed); |
| }} catch (_error) {{ |
| window.localStorage.removeItem(storageKey); |
| }} |
| }}, []); |
| |
| useEffect(() => {{ |
| window.localStorage.setItem(storageKey, JSON.stringify(records)); |
| }}, [records]); |
| |
| function saveRecord(event: FormEvent<HTMLFormElement>): void {{ |
| event.preventDefault(); |
| const hasValue = fields.some((field) => draft[field.id]?.trim()); |
| if (!hasValue) return; |
| const record: RecordItem = {{ |
| id: crypto.randomUUID(), |
| createdAt: new Date().toLocaleString(), |
| ...draft, |
| }}; |
| setRecords((current) => [record, ...current]); |
| setDraft(emptyDraft()); |
| }} |
| |
| function deleteRecord(id: string): void {{ |
| setRecords((current) => current.filter((record) => record.id !== id)); |
| }} |
| |
| function exportRecords(): void {{ |
| const columns = ["createdAt", ...fields.map((field) => field.id)]; |
| const rows: CsvRow[] = records.map((record) => |
| Object.fromEntries(columns.map((column) => [column, record[column] || ""])) as CsvRow, |
| ); |
| downloadText("{slugify(spec.project_name)}.csv", toCsv(rows, columns)); |
| }} |
| |
| return ( |
| <main className="shell"> |
| <section className="hero"> |
| <div> |
| <p className="eyebrow">Kaiju project harness</p> |
| <h1>{spec.project_name}</h1> |
| <p>{spec.description}</p> |
| <span className="status">{spec.project_type.replace("_", " ")}</span> |
| </div> |
| <form className="panel" onSubmit={{saveRecord}}> |
| <h2>Add record</h2> |
| <p>Capture {field_labels}. Records stay in this browser until you export or clear them.</p> |
| {{fields.map((field) => ( |
| <label className="field" key={{field.id}}> |
| {{field.label}} |
| <input |
| className="input" |
| value={{draft[field.id] || ""}} |
| onChange={{(event) => setDraft((current) => ({{ ...current, [field.id]: event.target.value }}))}} |
| placeholder={{field.label}} |
| /> |
| </label> |
| ))}} |
| <button className="btn" type="submit">Save locally</button> |
| </form> |
| </section> |
| |
| <section className="panel" style={{{{ marginTop: 22 }}}}> |
| <div style={{{{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}}}> |
| <div> |
| <p className="eyebrow">Workspace</p> |
| <h2>Saved records</h2> |
| </div> |
| <button className="btn" type="button" onClick={{exportRecords}} disabled={{records.length === 0}}> |
| Export CSV |
| </button> |
| </div> |
| <div className="grid" style={{{{ gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))" }}}}> |
| {{records.length === 0 ? ( |
| <article className="card"> |
| <h3>No records yet</h3> |
| <p>Add the first one to start using the app.</p> |
| </article> |
| ) : ( |
| records.map((record) => ( |
| <article className="card" key={{record.id}}> |
| <p className="eyebrow">{{record.createdAt}}</p> |
| {{fields.map((field) => ( |
| <p key={{field.id}}> |
| <strong>{{field.label}}:</strong> {{record[field.id] || "Not set"}} |
| </p> |
| ))}} |
| <button className="btn" type="button" onClick={{() => deleteRecord(record.id)}}> |
| Delete |
| </button> |
| </article> |
| )) |
| )}} |
| </div> |
| </section> |
| </main> |
| ); |
| }} |
| """ |
|
|
|
|
| def render_checkout_route() -> str: |
| return """import { NextRequest, NextResponse } from "next/server"; |
| import Stripe from "stripe"; |
| import { z } from "zod"; |
| |
| const CheckoutSchema = z.object({ |
| priceId: z.string().min(1) |
| }); |
| |
| function stripeClient() { |
| const secret = process.env.STRIPE_SECRET_KEY; |
| if (!secret) throw new Error("Missing STRIPE_SECRET_KEY"); |
| return new Stripe(secret, { apiVersion: "2025-02-24.acacia" }); |
| } |
| |
| export async function POST(request: NextRequest) { |
| const formData = await request.formData(); |
| const parsed = CheckoutSchema.safeParse({ priceId: formData.get("priceId") }); |
| if (!parsed.success) { |
| return NextResponse.json({ error: "Missing priceId" }, { status: 400 }); |
| } |
| |
| const origin = request.headers.get("origin") || "http://localhost:3000"; |
| const session = await stripeClient().checkout.sessions.create({ |
| mode: "payment", |
| line_items: [{ price: parsed.data.priceId, quantity: 1 }], |
| success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`, |
| cancel_url: `${origin}/cancel` |
| }); |
| |
| if (!session.url) { |
| return NextResponse.json({ error: "Stripe did not return a checkout URL" }, { status: 502 }); |
| } |
| |
| return NextResponse.redirect(session.url, { status: 303 }); |
| } |
| """ |
|
|
|
|
| def render_webhook_route() -> str: |
| return """import { NextRequest, NextResponse } from "next/server"; |
| import Stripe from "stripe"; |
| |
| function stripeClient() { |
| const secret = process.env.STRIPE_SECRET_KEY; |
| if (!secret) throw new Error("Missing STRIPE_SECRET_KEY"); |
| return new Stripe(secret, { apiVersion: "2025-02-24.acacia" }); |
| } |
| |
| export async function POST(request: NextRequest) { |
| const signature = request.headers.get("stripe-signature"); |
| const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; |
| if (!signature || !webhookSecret) { |
| return NextResponse.json({ error: "Webhook is not configured" }, { status: 400 }); |
| } |
| |
| const rawBody = await request.text(); |
| let event: Stripe.Event; |
| try { |
| event = stripeClient().webhooks.constructEvent(rawBody, signature, webhookSecret); |
| } catch (error) { |
| return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); |
| } |
| |
| if (event.type === "checkout.session.completed") { |
| const session = event.data.object as Stripe.Checkout.Session; |
| console.log("checkout complete", session.id); |
| } |
| |
| return NextResponse.json({ received: true }); |
| } |
| """ |
|
|
|
|
| def render_success_page(title: str, message: str) -> str: |
| return f"""export default function Page() {{ |
| return ( |
| <main className="shell"> |
| <section className="panel"> |
| <p className="eyebrow">Status</p> |
| <h1>{title}</h1> |
| <p>{message}</p> |
| </section> |
| </main> |
| ); |
| }} |
| """ |
|
|
|
|
| def render_readme(spec: CodeProjectSpec) -> str: |
| commands = "\n".join(f"- `{command}`" for command in spec.commands) |
| features = "\n".join(f"- {feature}" for feature in spec.features) |
| env = "" |
| if spec.project_type == "stripe_checkout": |
| env = """ |
| ## Environment |
| |
| Create `.env.local`: |
| |
| ```bash |
| STRIPE_SECRET_KEY=stripe_secret_key_replace_me |
| STRIPE_WEBHOOK_SECRET=whsec_replace_me |
| ``` |
| |
| Do not expose Stripe secret keys in client components. |
| """ |
| return f"""# {spec.project_name} |
| |
| {spec.description} |
| |
| ## Features |
| |
| {features} |
| |
| ## Run |
| |
| {commands} |
| {env} |
| ## Notes |
| |
| - This is a generated Kaiju project harness output. |
| - Review environment variables before using real payments. |
| - Run tests before shipping. |
| """ |
|
|
|
|
| def render_test(spec: CodeProjectSpec) -> str: |
| expected = json.dumps(spec.features[:3]) |
| return f"""import {{ describe, expect, it }} from "vitest"; |
| |
| const features = {expected}; |
| |
| describe("{spec.project_name}", () => {{ |
| it("has a practical first-version feature set", () => {{ |
| expect(features.length).toBeGreaterThanOrEqual(3); |
| expect(features.join(" ").toLowerCase()).not.toContain("placeholder copy"); |
| }}); |
| }}); |
| """ |
|
|
|
|
| def render_csv_utility() -> str: |
| return """export type CsvValue = string | number | boolean | null | undefined; |
| export type CsvRow = Record<string, CsvValue>; |
| |
| function escapeCsv(value: CsvValue): string { |
| const text = value === null || value === undefined ? "" : String(value); |
| if (/[",\\n]/.test(text)) { |
| return `"${text.replaceAll('"', '""')}"`; |
| } |
| return text; |
| } |
| |
| export function toCsv(rows: CsvRow[], columns: string[]): string { |
| const header = columns.map(escapeCsv).join(","); |
| const body = rows.map((row) => columns.map((column) => escapeCsv(row[column])).join(",")); |
| return [header, ...body].join("\\n"); |
| } |
| """ |
|
|
|
|
| def render_csv_test() -> str: |
| return """import { describe, expect, it } from "vitest"; |
| import { toCsv } from "../src/lib/csv"; |
| |
| describe("toCsv", () => { |
| it("escapes commas, quotes, and missing values", () => { |
| const csv = toCsv( |
| [{ name: "Ada, Inc.", note: 'Needs "premium"', missing: null }], |
| ["name", "note", "missing"], |
| ); |
| |
| expect(csv).toContain('"Ada, Inc."'); |
| expect(csv).toContain('"Needs ""premium""' + '"'); |
| expect(csv.endsWith(",")).toBe(true); |
| }); |
| }); |
| """ |
|
|
|
|
| def render_worker_index(spec: CodeProjectSpec) -> str: |
| title = spec.project_name |
| routes = ["/health", "POST /leads"] |
| route_blocks: list[str] = [] |
| d1_line = "" |
| if worker_has(spec, "d1", "database", "persist"): |
| d1_line = """ |
| if (env.DB) { |
| await env.DB.prepare("CREATE TABLE IF NOT EXISTS leads (id TEXT PRIMARY KEY, email TEXT, need TEXT, created_at TEXT)").run(); |
| await env.DB.prepare("INSERT INTO leads (id, email, need, created_at) VALUES (?, ?, ?, ?)").bind(lead.id, lead.email, lead.need, lead.createdAt).run(); |
| } |
| """ |
| if worker_has(spec, "telegram"): |
| routes.append("POST /telegram/webhook") |
| route_blocks.append(""" |
| if (url.pathname === "/telegram/webhook" && request.method === "POST") { |
| const update = await readJson(request); |
| if (!update || typeof update !== "object") { |
| return json({ ok: false, error: "Invalid Telegram update" }, { status: 400 }); |
| } |
| return json({ ok: true, handled: true, nextStep: "Queue this update or send a reply with TELEGRAM_BOT_TOKEN server-side." }); |
| } |
| """) |
| if worker_has(spec, "r2", "upload", "file"): |
| routes.append("POST /files") |
| route_blocks.append(""" |
| if (url.pathname === "/files" && request.method === "POST") { |
| if (!env.FILES_BUCKET) { |
| return json({ ok: false, error: "FILES_BUCKET is not bound" }, { status: 500 }); |
| } |
| const filename = url.searchParams.get("filename") || `upload-${crypto.randomUUID()}.bin`; |
| await env.FILES_BUCKET.put(filename, request.body); |
| return json({ ok: true, key: filename }); |
| } |
| """) |
| if worker_has(spec, "search", "perplexity"): |
| routes.append("POST /search") |
| route_blocks.append(""" |
| if (url.pathname === "/search" && request.method === "POST") { |
| if (!env.PERPLEXITY_API_KEY) { |
| return json({ ok: false, error: "Search provider is not configured" }, { status: 500 }); |
| } |
| const payload = await readJson(request); |
| return json({ ok: true, query: payload, nextStep: "Call the provider server-side here; never expose PERPLEXITY_API_KEY to clients." }); |
| } |
| """) |
| return f"""import {{ z }} from "zod"; |
| |
| export interface Env {{ |
| PUBLIC_APP_NAME?: string; |
| API_TOKEN_HASH?: string; |
| TELEGRAM_BOT_TOKEN?: string; |
| PERPLEXITY_API_KEY?: string; |
| FILES_BUCKET?: R2Bucket; |
| DB?: D1Database; |
| }} |
| |
| const LeadSchema = z.object({{ |
| name: z.string().min(2), |
| email: z.string().email(), |
| need: z.string().min(3), |
| source: z.string().optional() |
| }}); |
| |
| const corsHeaders = {{ |
| "Access-Control-Allow-Origin": "*", |
| "Access-Control-Allow-Methods": "GET,POST,OPTIONS", |
| "Access-Control-Allow-Headers": "Content-Type, Authorization" |
| }}; |
| |
| function json(data: unknown, init: ResponseInit = {{}}): Response {{ |
| return new Response(JSON.stringify(data, null, 2), {{ |
| ...init, |
| headers: {{ |
| "Content-Type": "application/json; charset=utf-8", |
| ...corsHeaders, |
| ...(init.headers || {{}}) |
| }} |
| }}); |
| }} |
| |
| async function readJson(request: Request): Promise<unknown> {{ |
| try {{ |
| return await request.json(); |
| }} catch (_error) {{ |
| return null; |
| }} |
| }} |
| |
| async function sha256Hex(value: string): Promise<string> {{ |
| const bytes = new TextEncoder().encode(value); |
| const digest = await crypto.subtle.digest("SHA-256", bytes); |
| return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); |
| }} |
| |
| function timingSafeEqual(a: string, b: string): boolean {{ |
| if (a.length !== b.length) return false; |
| let mismatch = 0; |
| for (let index = 0; index < a.length; index += 1) {{ |
| mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index); |
| }} |
| return mismatch === 0; |
| }} |
| |
| async function requireBearer(request: Request, env: Env): Promise<Response | null> {{ |
| if (!env.API_TOKEN_HASH) return null; |
| const supplied = request.headers.get("authorization") || ""; |
| if (!supplied.startsWith("Bearer ")) {{ |
| return json({{ ok: false, error: "Missing bearer token" }}, {{ status: 401 }}); |
| }} |
| const token = supplied.slice("Bearer ".length).trim(); |
| const tokenHash = await sha256Hex(token); |
| if (!timingSafeEqual(tokenHash, env.API_TOKEN_HASH)) {{ |
| return json({{ ok: false, error: "Unauthorized" }}, {{ status: 401 }}); |
| }} |
| return null; |
| }} |
| |
| export default {{ |
| async fetch(request: Request, env: Env): Promise<Response> {{ |
| const url = new URL(request.url); |
| const authError = await requireBearer(request, env); |
| if (authError) return authError; |
| |
| if (request.method === "OPTIONS") {{ |
| return new Response(null, {{ headers: corsHeaders }}); |
| }} |
| |
| if (url.pathname === "/health") {{ |
| return json({{ ok: true, service: env.PUBLIC_APP_NAME || "{title}" }}); |
| }} |
| |
| if (url.pathname === "/leads" && request.method === "POST") {{ |
| const parsed = LeadSchema.safeParse(await readJson(request)); |
| if (!parsed.success) {{ |
| return json({{ ok: false, error: "Invalid lead payload", issues: parsed.error.flatten() }}, {{ status: 400 }}); |
| }} |
| |
| const lead = {{ |
| id: crypto.randomUUID(), |
| createdAt: new Date().toISOString(), |
| ...parsed.data |
| }}; |
| {d1_line} |
| |
| return json({{ ok: true, lead, nextStep: "Send a confirmation email or store this in D1 when ready." }}, {{ status: 201 }}); |
| }} |
| {''.join(route_blocks)} |
| |
| return json({{ |
| ok: true, |
| service: env.PUBLIC_APP_NAME || "{title}", |
| routes: {json.dumps(routes)}, |
| features: {json.dumps(spec.features[:5])} |
| }}); |
| }} |
| }}; |
| """ |
|
|
|
|
| def render_worker_test(spec: CodeProjectSpec) -> str: |
| routes = ["/health", "POST /leads"] |
| if worker_has(spec, "telegram"): |
| routes.append("POST /telegram/webhook") |
| if worker_has(spec, "r2", "upload", "file"): |
| routes.append("POST /files") |
| if worker_has(spec, "search", "perplexity"): |
| routes.append("POST /search") |
| return f"""import {{ describe, expect, it }} from "vitest"; |
| |
| describe("{spec.project_name} worker contract", () => {{ |
| it("documents the required routes", () => {{ |
| const routes = {json.dumps(routes)}; |
| expect(routes).toContain("/health"); |
| expect(routes).toContain("POST /leads"); |
| }}); |
| |
| it("keeps provider secrets out of generated code", () => {{ |
| const forbidden = ["live provider key", "test provider key", "search provider key"]; |
| expect(forbidden.join(" ")).not.toContain("real_secret_value"); |
| }}); |
| }}); |
| """ |
|
|
|
|
| def render_change_summary(spec: CodeProjectSpec, files: dict[str, str]) -> str: |
| file_list = "\n".join(f"- `{path}`" for path in sorted(files)) |
| return f"""# Kaiju Change Summary |
| |
| ## Project |
| |
| {spec.project_name} |
| |
| ## Type |
| |
| {spec.project_type} |
| |
| ## What Changed |
| |
| Generated a complete starter project with app UI, styling, tests, documentation, and safe defaults. |
| |
| ## Files |
| |
| {file_list} |
| |
| ## Verification |
| |
| - Parse `package.json`. |
| - Confirm required source files exist. |
| - Confirm no provider secret is hardcoded. |
| - Run `npm run test` after dependencies are installed. |
| """ |
|
|
|
|
| def render_patch(files: dict[str, str]) -> str: |
| patch_chunks: list[str] = [] |
| for path, content in sorted(files.items()): |
| diff = difflib.unified_diff( |
| [], |
| content.splitlines(keepends=True), |
| fromfile=f"a/{path}", |
| tofile=f"b/{path}", |
| ) |
| patch_chunks.append("".join(diff)) |
| return "\n".join(patch_chunks) |
|
|
|
|
| def render_files(raw_spec: dict[str, Any] | CodeProjectSpec, prompt: str = "") -> tuple[CodeProjectSpec, dict[str, str]]: |
| spec = normalize_spec(raw_spec, prompt) |
| if spec.project_type == "cloudflare_worker": |
| files = { |
| "package.json": render_package_json(spec), |
| "tsconfig.json": render_worker_tsconfig(), |
| "wrangler.toml": render_wrangler_toml(spec), |
| "src/index.ts": render_worker_index(spec), |
| "tests/worker.test.ts": render_worker_test(spec), |
| "README.md": render_readme(spec), |
| } |
| files["kaiju-change-summary.md"] = render_change_summary(spec, files) |
| files["kaiju.patch"] = render_patch(files) |
| return spec, files |
|
|
| files: dict[str, str] = { |
| "package.json": render_package_json(spec), |
| "tsconfig.json": render_tsconfig(), |
| "next.config.js": render_next_config(), |
| "src/app/layout.tsx": render_layout(spec), |
| "src/app/globals.css": render_css(), |
| "src/app/page.tsx": render_page(spec) if spec.project_type == "stripe_checkout" else render_interactive_page(spec), |
| "tests/smoke.test.ts": render_test(spec), |
| "README.md": render_readme(spec), |
| } |
| if spec.project_type == "stripe_checkout": |
| files["src/app/api/checkout/route.ts"] = render_checkout_route() |
| files["src/app/api/webhooks/stripe/route.ts"] = render_webhook_route() |
| files["src/app/success/page.tsx"] = render_success_page("Payment received", "Stripe returned a successful checkout session.") |
| files["src/app/cancel/page.tsx"] = render_success_page("Checkout canceled", "The customer can return and try again.") |
| else: |
| files["src/lib/csv.ts"] = render_csv_utility() |
| files["tests/csv.test.ts"] = render_csv_test() |
| files["kaiju-change-summary.md"] = render_change_summary(spec, files) |
| files["kaiju.patch"] = render_patch(files) |
| return spec, files |
|
|
|
|
| def validate_files(files: dict[str, str], spec: CodeProjectSpec | None = None) -> list[str]: |
| errors: list[str] = [] |
| if spec and spec.project_type == "cloudflare_worker": |
| required = ["package.json", "tsconfig.json", "wrangler.toml", "src/index.ts", "tests/worker.test.ts", "README.md", "kaiju-change-summary.md", "kaiju.patch"] |
| else: |
| required = ["package.json", "tsconfig.json", "next.config.js", "src/app/layout.tsx", "src/app/page.tsx", "src/app/globals.css", "tests/smoke.test.ts", "README.md", "kaiju-change-summary.md", "kaiju.patch"] |
| for path in required: |
| if path not in files: |
| errors.append(f"missing file: {path}") |
| try: |
| package = json.loads(files.get("package.json", "{}")) |
| except json.JSONDecodeError as exc: |
| errors.append(f"invalid package.json: {exc}") |
| package = {} |
| scripts = package.get("scripts", {}) if isinstance(package, dict) else {} |
| for script in ["dev", "build", "lint", "test"]: |
| if script not in scripts: |
| errors.append(f"missing npm script: {script}") |
| combined = "\n".join(files.values()).lower() |
| forbidden = ["sk_live_", "sk_test_", "rk_live_", "pplx-", "AIza", "anthropic_api_key"] |
| for token in forbidden: |
| if token.lower() in combined: |
| errors.append(f"forbidden secret token: {token}") |
| if "lorem ipsum" in combined: |
| errors.append("lorem ipsum found") |
| if spec and spec.project_type == "stripe_checkout": |
| for path in ["src/app/api/checkout/route.ts", "src/app/api/webhooks/stripe/route.ts"]: |
| if path not in files: |
| errors.append(f"missing Stripe route: {path}") |
| if "process.env.stripe_secret_key" not in combined: |
| errors.append("Stripe secret is not server-side env based") |
| if "constructevent" not in combined: |
| errors.append("missing Stripe webhook signature verification") |
| if spec and spec.project_type in {"booking_app", "crm_app", "dashboard", "invoice_app", "estimate_app", "content_calendar", "expense_tracker"}: |
| page = files.get("src/app/page.tsx", "") |
| for path in ["src/lib/csv.ts", "tests/csv.test.ts"]: |
| if path not in files: |
| errors.append(f"missing interactive app support file: {path}") |
| for token in ['"use client"', "localStorage", "Export CSV", "Delete", "Save locally"]: |
| if token not in page: |
| errors.append(f"interactive app page missing token: {token}") |
| if "tocsv" not in combined: |
| errors.append("interactive app missing CSV export utility") |
| if spec and spec.project_type == "cloudflare_worker": |
| for path in ["wrangler.toml", "src/index.ts", "tests/worker.test.ts"]: |
| if path not in files: |
| errors.append(f"missing Worker file: {path}") |
| worker = files.get("src/index.ts", "") |
| wrangler = files.get("wrangler.toml", "") |
| if "export default" not in worker: |
| errors.append("missing Worker fetch export") |
| if "access-control-allow-origin" not in combined: |
| errors.append("missing CORS handling") |
| if "/health" not in combined or "/leads" not in combined: |
| errors.append("missing health/leads routes") |
| if worker_has(spec, "telegram") and ("/telegram/webhook" not in worker or "TELEGRAM_BOT_TOKEN" not in worker): |
| errors.append("missing Telegram webhook route or env") |
| if worker_has(spec, "r2", "upload", "file") and ("/files" not in worker or "FILES_BUCKET" not in worker or "[[r2_buckets]]" not in wrangler): |
| errors.append("missing R2 upload route or binding") |
| if worker_has(spec, "d1", "database", "persist") and ("env.DB" not in worker or "[[d1_databases]]" not in wrangler): |
| errors.append("missing D1 persistence route logic or binding") |
| if worker_has(spec, "search", "perplexity") and ("/search" not in worker or "PERPLEXITY_API_KEY" not in worker): |
| errors.append("missing server-side search proxy route or env") |
| if worker_has(spec, "auth", "license", "rate limit", "api key"): |
| auth_required = ["API_TOKEN_HASH", "requireBearer", "crypto.subtle.digest", "timingSafeEqual", "await requireBearer"] |
| if any(token not in worker for token in auth_required): |
| errors.append("missing verified bearer auth guard") |
| return errors |
|
|
|
|
| def write_project(root: Path, files: dict[str, str]) -> None: |
| root.mkdir(parents=True, exist_ok=True) |
| for relative, content in files.items(): |
| path = root / relative |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text(content, encoding="utf-8") |
|
|
|
|
| def render_from_prompt(prompt: str) -> tuple[CodeProjectSpec, dict[str, str], list[str]]: |
| spec, files = render_files(spec_from_prompt(prompt), prompt) |
| return spec, files, validate_files(files, spec) |
|
|
|
|
| def spec_to_json(spec: CodeProjectSpec) -> str: |
| return json.dumps(spec.__dict__, indent=2, ensure_ascii=False) |
|
|