diff --git a/CHANGELOG.md b/CHANGELOG.md index 755a1731..01f67225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,24 @@ All notable changes to LynxPrompt will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.6.0] - February 2026 + +### Added +- **MCP Servers config**: New wizard field (CLI + WebUI) to list MCP servers the developer uses. Generated output tells the AI to use them when relevant. +- **Workaround behavior toggle**: New toggle to control whether the AI should attempt creative workarounds when stuck, or stop and ask. +- **Infrastructure questions**: Conditional wizard questions for SSH server access (key path) and manual deployment method (Portainer, Docker Compose, Kubernetes, bare metal). +- **Burke Holland-inspired AI rules**: New behavior options β€” "Code for LLMs", "Self-Improving Config", "Always Verify Work", "Terminal Management", "Check Docs First". +- **Paid template variable preview**: Locked paid templates now show their customizable variables (name + default) so users can see what they'll get before purchasing. +- **Socialify banner**: Added dynamic Socialify image to README for better GitHub social previews. +- **XCTest support**: Now available in test frameworks (via shared package import). + +### Changed +- **Persona wording**: Generated output now uses "Developer background: X. Adapt your suggestions..." instead of "I am X" or "You are X" β€” properly tells the AI about the developer's expertise without roleplaying. +- **CLI wizard refactored**: Languages, frameworks, databases, and test frameworks now imported from the shared package instead of being hardcoded in the CLI. +- **CLI agent sessions hint**: Improved explanation for the "multiple AI agent sessions" wizard question. + +### Removed +- **ClickHouse references**: Cleaned up remaining ClickHouse mentions from ROADMAP.md documentation. --- diff --git a/README.md b/README.md index 7958198e..d4aa812c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ - - - - LynxPrompt - + + LynxPrompt + # LynxPrompt diff --git a/cli/src/commands/wizard.ts b/cli/src/commands/wizard.ts index 3b8996ec..7ea04741 100644 --- a/cli/src/commands/wizard.ts +++ b/cli/src/commands/wizard.ts @@ -9,6 +9,12 @@ import { detectProject, detectFromRemoteUrl, isGitUrl } from "../utils/detect.js import { generateConfig, GenerateOptions, parseVariablesString } from "../utils/generator.js"; import { isAuthenticated, getUser } from "../config.js"; import { api, ApiRequestError } from "../api.js"; +import { + LANGUAGES as SHARED_LANGUAGES, + FRAMEWORKS as SHARED_FRAMEWORKS, + DATABASES as SHARED_DATABASES, + TEST_FRAMEWORKS as SHARED_TEST_FRAMEWORKS, +} from "../utils/wizard-options.js"; // Draft management - local storage in .lynxprompt/drafts/ const DRAFTS_DIR = ".lynxprompt/drafts"; @@ -238,52 +244,10 @@ const ALL_PLATFORMS = [ ]; -// Languages -const LANGUAGES = [ - { title: "πŸ”· TypeScript", value: "typescript" }, - { title: "🟑 JavaScript", value: "javascript" }, - { title: "🐍 Python", value: "python" }, - { title: "πŸ”΅ Go", value: "go" }, - { title: "πŸ¦€ Rust", value: "rust" }, - { title: "β˜• Java", value: "java" }, - { title: "πŸ’œ C#/.NET", value: "csharp" }, - { title: "πŸ’Ž Ruby", value: "ruby" }, - { title: "🐘 PHP", value: "php" }, - { title: "🍎 Swift", value: "swift" }, - { title: "πŸ”Ά Kotlin", value: "kotlin" }, - { title: "⬛ C/C++", value: "cpp" }, -]; - -// Frameworks -const FRAMEWORKS = [ - { title: "βš›οΈ React", value: "react" }, - { title: "β–² Next.js", value: "nextjs" }, - { title: "πŸ’š Vue.js", value: "vue" }, - { title: "πŸ…°οΈ Angular", value: "angular" }, - { title: "πŸ”₯ Svelte", value: "svelte" }, - { title: "πŸš‚ Express", value: "express" }, - { title: "⚑ FastAPI", value: "fastapi" }, - { title: "🎸 Django", value: "django" }, - { title: "πŸ§ͺ Flask", value: "flask" }, - { title: "πŸƒ Spring", value: "spring" }, - { title: "πŸ’Ž Rails", value: "rails" }, - { title: "πŸ”΄ Laravel", value: "laravel" }, - { title: "πŸ—οΈ NestJS", value: "nestjs" }, - { title: "⚑ Vite", value: "vite" }, - { title: "πŸ“± React Native", value: "react-native" }, -]; - -// Databases -const DATABASES = [ - { title: "🐘 PostgreSQL", value: "postgresql" }, - { title: "🐬 MySQL", value: "mysql" }, - { title: "πŸƒ MongoDB", value: "mongodb" }, - { title: "πŸ”΄ Redis", value: "redis" }, - { title: "πŸ“Š SQLite", value: "sqlite" }, - { title: "☁️ Supabase", value: "supabase" }, - { title: "πŸ”₯ Firebase", value: "firebase" }, - { title: "πŸ“‚ Prisma", value: "prisma" }, -]; +// Languages, frameworks, and databases imported from shared package (wizard-options.ts) +const LANGUAGES = SHARED_LANGUAGES; +const FRAMEWORKS = SHARED_FRAMEWORKS; +const DATABASES = SHARED_DATABASES; // Package managers (JS/TS only) const PACKAGE_MANAGERS = [ @@ -641,6 +605,12 @@ const AI_BEHAVIOR_RULES = [ { id: "run_tests_before_commit", label: "Run Tests Before Commit", description: "AI must run the test suite and ensure all tests pass before suggesting a commit", recommended: true }, { id: "follow_existing_patterns", label: "Follow Existing Patterns", description: "AI should study existing code and follow the same naming, structure, and conventions used in the codebase", recommended: true }, { id: "ask_before_large_refactors", label: "Ask Before Large Refactors", description: "AI should always ask for explicit approval before making significant architectural changes or refactoring multiple files", recommended: true }, + // Burke Holland-inspired rules + { id: "code_for_llms", label: "Code for LLMs", description: "Optimize code for LLM reasoning: flat/explicit patterns, minimal abstractions, structured logging" }, + { id: "self_improving", label: "Self-Improving Config", description: "AI updates this config file when it learns new project patterns or conventions" }, + { id: "verify_work", label: "Always Verify Work", description: "Run tests, check builds, and confirm changes work before returning control", recommended: true }, + { id: "terminal_management", label: "Terminal Management", description: "Reuse existing terminals and close unused ones" }, + { id: "check_docs_first", label: "Check Docs First", description: "Check documentation via MCP or project docs before assuming knowledge about APIs" }, ]; // ═══════════════════════════════════════════════════════════════ @@ -789,49 +759,8 @@ const BOUNDARY_OPTIONS = [ "Skip tests temporarily", ]; -// Testing frameworks - expanded to match WebUI -const TEST_FRAMEWORKS = [ - // JavaScript/TypeScript - "jest", "vitest", "mocha", "ava", "tap", "bun:test", - // E2E/Integration - "playwright", "cypress", "puppeteer", "selenium", "webdriverio", "testcafe", - // React/Frontend - "rtl", "enzyme", "storybook", "chromatic", - // API/Mocking - "msw", "supertest", "pact", "dredd", "karate", "postman", "insomnia", - // Python - "pytest", "unittest", "nose2", "hypothesis", "behave", "robot", - // Go - "go-test", "testify", "ginkgo", "gomega", - // Java/JVM - "junit", "testng", "mockito", "spock", "cucumber-jvm", - // Ruby - "rspec", "minitest", "capybara", "factory_bot", - // .NET - "xunit", "nunit", "mstest", "specflow", - // Infrastructure/DevOps - "terratest", "conftest", "opa", "inspec", "serverspec", "molecule", "kitchen", "goss", - // Kubernetes - "kubetest", "kuttl", "chainsaw", "helm-unittest", - // Security - "owasp-zap", "burpsuite", "nuclei", "semgrep", - // Load/Performance - "k6", "locust", "jmeter", "artillery", "gatling", "vegeta", "wrk", "ab", - // Chaos Engineering - "chaos-mesh", "litmus", "gremlin", "toxiproxy", - // Contract Testing - "spring-cloud-contract", "specmatic", - // BDD - "cucumber", "gauge", "concordion", - // Mutation Testing - "stryker", "pitest", "mutmut", - // Fuzzing - "go-fuzz", "afl", "libfuzzer", "jazzer", - // PHP - "phpunit", "pest", "codeception", - // Rust - "cargo-test", "rstest", "proptest", -]; +// Testing frameworks imported from shared package (includes XCTest, Espresso, etc.) +const TEST_FRAMEWORKS: string[] = SHARED_TEST_FRAMEWORKS; // Test levels const TEST_LEVELS = [ @@ -2250,7 +2179,7 @@ async function runInteractiveWizard( name: "useGitWorktrees", message: chalk.white("🌲 Do you plan on working with several AI agent sessions in this repository?"), initial: true, - hint: "If yes, AI will be instructed to always use git worktrees for each task", + hint: "Enable if you use multiple AI agents (Cursor, Claude, Copilot) in parallel. Each task gets its own git worktree to prevent branch conflicts.", }, promptConfig); answers.useGitWorktrees = useGitWorktreesResponse.useGitWorktrees ?? true; @@ -3163,6 +3092,17 @@ async function runInteractiveWizard( }, promptConfig); answers.explanationVerbosity = verbosityResponse.explanationVerbosity || "balanced"; + // Workaround behavior + const workaroundResponse = await prompts({ + type: "toggle", + name: "attemptWorkarounds", + message: chalk.white("When stuck, should the AI attempt workarounds?"), + initial: true, + active: "Yes, try workarounds", + inactive: "No, stop and ask", + }, promptConfig); + answers.attemptWorkarounds = workaroundResponse.attemptWorkarounds ?? true; + // Focus areas const accessibilityResponse = await prompts({ type: "toggle", @@ -3184,6 +3124,68 @@ async function runInteractiveWizard( }, promptConfig); answers.performanceFocus = performanceResponse.performanceFocus ?? false; + // MCP Servers + console.log(); + console.log(chalk.gray(" πŸ”Œ MCP (Model Context Protocol) servers let the AI interact with external tools")); + console.log(chalk.gray(" like databases, APIs, file systems, and more. List any you have configured.")); + const mcpServersResponse = await prompts({ + type: "text", + name: "mcpServers", + message: chalk.white("MCP servers (comma-separated, or leave empty):"), + hint: chalk.gray("e.g. filesystem, github, postgres, docker"), + }, promptConfig); + answers.mcpServers = mcpServersResponse.mcpServers || ""; + + // Server access + const serverAccessResponse = await prompts({ + type: "toggle", + name: "serverAccess", + message: chalk.white("Does this project require logging into a server?"), + initial: false, + active: "Yes", + inactive: "No", + }, promptConfig); + answers.serverAccess = serverAccessResponse.serverAccess ?? false; + + if (answers.serverAccess) { + const sshKeyPathResponse = await prompts({ + type: "text", + name: "sshKeyPath", + message: chalk.white("SSH key path (leave empty for default ~/.ssh/):"), + hint: chalk.gray("e.g. ~/.ssh/id_ed25519"), + }, promptConfig); + answers.sshKeyPath = sshKeyPathResponse.sshKeyPath || ""; + } + + // Manual deployment (only ask if no CI/CD was selected) + const hasCicd = (answers.cicd as string[])?.length > 0; + if (!hasCicd) { + const manualDeployResponse = await prompts({ + type: "toggle", + name: "manualDeployment", + message: chalk.white("Do you deploy manually (no CI/CD)?"), + initial: false, + active: "Yes", + inactive: "No", + }, promptConfig); + answers.manualDeployment = manualDeployResponse.manualDeployment ?? false; + + if (answers.manualDeployment) { + const deployMethodResponse = await prompts({ + type: "select", + name: "deploymentMethod", + message: chalk.white("How do you deploy?"), + choices: [ + { title: "🐳 Portainer (GitOps stacks)", value: "portainer" }, + { title: "πŸ“¦ Docker Compose (manual)", value: "docker_compose" }, + { title: "☸️ Kubernetes (kubectl)", value: "kubernetes" }, + { title: "πŸ–₯️ Bare metal (direct)", value: "bare_metal" }, + ], + }, promptConfig); + answers.deploymentMethod = deployMethodResponse.deploymentMethod || ""; + } + } + console.log(); console.log(chalk.gray(" πŸ“ Select files the AI should read first to understand your project context.")); console.log(chalk.gray(" These help the AI understand your codebase, APIs, and conventions.")); @@ -3995,5 +3997,15 @@ async function runInteractiveWizard( additionalLibraries: answers.additionalLibraries as string, // Docker image names dockerImageNames: answers.dockerImageNames as string, + // MCP servers + mcpServers: answers.mcpServers as string, + // Workaround behavior + attemptWorkarounds: answers.attemptWorkarounds as boolean, + // Server access + serverAccess: answers.serverAccess as boolean, + sshKeyPath: answers.sshKeyPath as string, + // Manual deployment + manualDeployment: answers.manualDeployment as boolean, + deploymentMethod: answers.deploymentMethod as string, }; } diff --git a/cli/src/utils/generator.ts b/cli/src/utils/generator.ts index 788abe82..15326ca6 100644 --- a/cli/src/utils/generator.ts +++ b/cli/src/utils/generator.ts @@ -91,6 +91,16 @@ export interface GenerateOptions { additionalLibraries?: string; // comma-separated (e.g., "Telethon, APScheduler, alembic") // Docker image names dockerImageNames?: string; // comma-separated (e.g., "myuser/myapp, myuser/myapp-viewer") + // MCP servers the developer uses (e.g., "filesystem, github, postgres") + mcpServers?: string; + // Whether AI should attempt workarounds when stuck, or stop and ask + attemptWorkarounds?: boolean; + // Server/SSH access + serverAccess?: boolean; // Whether the project requires server access + sshKeyPath?: string; // SSH key path (empty = default location) + // Manual deployment (when no CI/CD) + manualDeployment?: boolean; // Whether deployment is manual (no CI/CD) + deploymentMethod?: string; // portainer, docker_compose, kubernetes, bare_metal, etc. } /** @@ -189,7 +199,7 @@ const PLATFORM_FILES: Record = { firebender: "firebender.json", }; -// Persona descriptions +// Persona descriptions - these describe the developer's background so the AI can adapt const PERSONA_DESCRIPTIONS: Record = { backend: "a senior backend developer specializing in APIs, databases, and microservices architecture", frontend: "a senior frontend developer specializing in UI components, styling, and user experience", @@ -259,6 +269,12 @@ const AI_BEHAVIOR_DESCRIPTIONS: Record = { test_first: "Write tests before implementing new functionality (TDD)", no_console: "Remove console.log/print statements before committing", type_strict: "Be strict with types - avoid any/Any/Object types", + // Burke Holland-inspired rules + code_for_llms: "Optimize code for LLM reasoning: prefer flat/explicit patterns, minimal abstractions, structured logging, and linear control flow", + self_improving: "When you learn new project patterns or conventions, suggest updates to this configuration file", + verify_work: "Always verify your work before returning: run tests, check builds, confirm changes work as expected", + terminal_management: "Reuse existing terminals when possible. Close terminals you no longer need", + check_docs_first: "Always check documentation (via MCP or project docs) before assuming knowledge about APIs or libraries", }; // Important files descriptions - must match wizard options @@ -787,16 +803,15 @@ function generateFileContent(options: GenerateOptions, platform: string): string if (isMarkdown || isMdc) { sections.push("## Persona"); sections.push(""); + sections.push(`You assist developers working on ${projectName}.`); if (personaDesc) { - sections.push(`You are ${personaDesc}. You assist developers working on ${projectName}.`); - } else { - sections.push(`You assist developers working on ${projectName}.`); + sections.push(""); + sections.push(`Developer background: ${personaDesc}. Adapt your suggestions and level of explanation to this expertise.`); } } else { + sections.push(`You assist developers working on ${projectName}.`); if (personaDesc) { - sections.push(`You are ${personaDesc}. You assist developers working on ${projectName}.`); - } else { - sections.push(`You assist developers working on ${projectName}.`); + sections.push(`Developer background: ${personaDesc}. Adapt your suggestions and level of explanation to this expertise.`); } } @@ -1018,6 +1033,12 @@ function generateFileContent(options: GenerateOptions, platform: string): string sections.push(`- ${planModeDescriptions[options.planModeFrequency]}`); } } + // Workaround behavior + if (options.attemptWorkarounds === true) { + sections.push("- When stuck, **attempt creative workarounds** before asking for help"); + } else if (options.attemptWorkarounds === false) { + sections.push("- When stuck, **stop and ask** rather than attempting workarounds"); + } sections.push(""); } } @@ -1037,6 +1058,43 @@ function generateFileContent(options: GenerateOptions, platform: string): string sections.push(""); } + // MCP Servers + if (options.mcpServers) { + const servers = options.mcpServers.split(",").map(s => s.trim()).filter(Boolean); + if (servers.length > 0 && (isMarkdown || isMdc)) { + sections.push("## MCP Servers"); + sections.push(""); + sections.push("The developer has these MCP (Model Context Protocol) servers available. Use them when relevant:"); + sections.push(""); + for (const server of servers) { + sections.push(`- ${server}`); + } + sections.push(""); + } + } + + // Server access & deployment + if ((options.serverAccess || options.manualDeployment) && (isMarkdown || isMdc)) { + sections.push("## Infrastructure"); + sections.push(""); + if (options.serverAccess) { + const keyInfo = options.sshKeyPath + ? `SSH key: \`${options.sshKeyPath}\`` + : "SSH key in default location (~/.ssh/)"; + sections.push(`- **Server access**: via SSH. ${keyInfo}`); + } + if (options.manualDeployment && options.deploymentMethod) { + const methods: Record = { + portainer: "Portainer (GitOps stacks)", + docker_compose: "Docker Compose (manual)", + kubernetes: "Kubernetes (kubectl apply)", + bare_metal: "Bare metal (direct deployment)", + }; + sections.push(`- **Deployment**: ${methods[options.deploymentMethod] || options.deploymentMethod}`); + } + sections.push(""); + } + // Important files if (options.importantFiles && options.importantFiles.length > 0) { if (isMarkdown || isMdc) { diff --git a/docker-compose.yml b/docker-compose.yml index 3eb65a76..4de70325 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -159,42 +159,6 @@ services: security_opt: - no-new-privileges:true - # ========================================================================== - # CLICKHOUSE - Analytics & Event Data (OLAP) - # High-performance analytics for user behavior, trends, recommendations - # ========================================================================== - clickhouse: - image: clickhouse/clickhouse-server:25-alpine - container_name: lynxprompt-clickhouse - restart: unless-stopped - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" - environment: - CLICKHOUSE_DB: lynxprompt_analytics - CLICKHOUSE_USER: lynxprompt - CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-dev_clickhouse_password_change_me} - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 - volumes: - - clickhouse_data:/var/lib/clickhouse - - clickhouse_logs:/var/log/clickhouse-server - networks: - - lynxprompt - deploy: - resources: - limits: - memory: 2G - reservations: - memory: 512M - security_opt: - - no-new-privileges:true - ulimits: - nofile: - soft: 262144 - hard: 262144 - # ========================================================================== # LYNXPROMPT APPLICATION # ========================================================================== @@ -218,8 +182,6 @@ services: condition: service_healthy postgres-support: condition: service_healthy - clickhouse: - condition: service_started deploy: resources: limits: @@ -243,12 +205,6 @@ services: DATABASE_URL_USERS: postgresql://lynxprompt_users:${POSTGRES_USERS_PASSWORD:-dev_users_password_change_me}@postgres-users:5432/lynxprompt_users?schema=public DATABASE_URL_BLOG: postgresql://lynxprompt_blog:${POSTGRES_BLOG_PASSWORD:-dev_blog_password_change_me}@postgres-blog:5432/lynxprompt_blog?schema=public DATABASE_URL_SUPPORT: postgresql://lynxprompt_support:${POSTGRES_SUPPORT_PASSWORD:-dev_support_password_change_me}@postgres-support:5432/lynxprompt_support?schema=public - # ClickHouse Analytics - CLICKHOUSE_HOST: clickhouse - CLICKHOUSE_PORT: 8123 - CLICKHOUSE_DB: lynxprompt_analytics - CLICKHOUSE_USER: lynxprompt - CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-dev_clickhouse_password_change_me} # NextAuth NEXTAUTH_URL: http://localhost:3000 NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-dev-secret-change-in-production} @@ -294,8 +250,6 @@ volumes: postgres_users_keyring: postgres_blog_data: postgres_support_data: - clickhouse_data: - clickhouse_logs: uploads_data: networks: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a9333297..3ca1c5fe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -126,7 +126,6 @@ Per EU Consumer Rights Directive, digital content can waive 14-day withdrawal IF - [x] Project scaffolding with Next.js 15, React 19, TypeScript - [x] PostgreSQL database with Prisma ORM (dual-database architecture) -- [x] ClickHouse for analytics (self-hosted EU) - [x] Umami analytics (self-hosted EU, cookieless) - [x] Authentication with NextAuth.js (GitHub, Google, Magic Link, Passkeys) - [x] Homepage with platform carousel @@ -437,7 +436,7 @@ When downloading, user sees: #### Template Analytics -- [ ] Track template downloads/usage (ClickHouse) +- [ ] Track template downloads/usage - [ ] Show trending templates - [ ] Usage statistics for template authors - [ ] Revenue reports for paid templates @@ -743,7 +742,6 @@ POST /api/generate - Generate config files from wizard data ### Current Infrastructure - [x] PostgreSQL (4 databases: app, users, blog, support) -- [x] ClickHouse (self-hosted EU, analytics) - [x] Umami (self-hosted EU, cookieless analytics) - [x] Docker deployment with GitOps (Portainer) - [x] Cloudflare DDoS protection and WAF @@ -760,7 +758,7 @@ POST /api/generate - Generate config files from wizard data - [ ] Annual third-party penetration test - [ ] Bug bounty program (HackerOne or similar) -> **Note:** GlitchTip is preferred over Sentry for self-hosted error tracking. It integrates well with our existing ClickHouse setup and keeps all data in EU. +> **Note:** GlitchTip is preferred over Sentry for self-hosted error tracking. It keeps all data in EU. --- diff --git a/env.example b/env.example index e218acc3..77ca6b78 100644 --- a/env.example +++ b/env.example @@ -27,15 +27,6 @@ POSTGRES_USERS_PASSWORD=dev_users_password_change_me POSTGRES_BLOG_PASSWORD=dev_blog_password_change_me POSTGRES_SUPPORT_PASSWORD=dev_support_password_change_me -# ============================================================================= -# CLICKHOUSE - Analytics & Event Tracking -# ============================================================================= -CLICKHOUSE_HOST=localhost -CLICKHOUSE_PORT=8123 -CLICKHOUSE_DB=lynxprompt_analytics -CLICKHOUSE_USER=lynxprompt -CLICKHOUSE_PASSWORD=dev_clickhouse_password_change_me - # ============================================================================= # NEXTAUTH - Authentication # ============================================================================= diff --git a/package-lock.json b/package-lock.json index 51176248..5a9a2c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lynxprompt", - "version": "1.4.41", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lynxprompt", - "version": "1.4.41", + "version": "1.6.0", "license": "GPL-3.0", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 47e52a90..e42ef9b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lynxprompt", - "version": "1.4.43", + "version": "1.6.0", "private": true, "description": "LynxPrompt - Transform your development setup into a mouse-click experience with AI IDE configuration generation", "author": "LynxPrompt Contributors", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts deleted file mode 100644 index 9364eaf9..00000000 --- a/src/app/api/analytics/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - trackEvent, - type AnalyticsEvent, - type AnalyticsEventType, -} from "@/lib/analytics/clickhouse"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import crypto from "crypto"; - -// Valid event types -const VALID_EVENT_TYPES: AnalyticsEventType[] = [ - "page_view", - "template_view", - "template_download", - "template_favorite", - "template_search", - "wizard_step", - "wizard_complete", - "wizard_abandon", - "feature_use", - "error", -]; - -// Rate limiting: simple in-memory store (per IP) -const rateLimitStore = new Map(); -const RATE_LIMIT_MAX = 100; // 100 events per minute per IP -const RATE_LIMIT_WINDOW_MS = 60000; - -function checkRateLimit(ip: string): boolean { - const now = Date.now(); - const record = rateLimitStore.get(ip); - - if (!record || now > record.resetTime) { - rateLimitStore.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS }); - return true; - } - - if (record.count >= RATE_LIMIT_MAX) { - return false; - } - - record.count++; - return true; -} - -// Clean up old rate limit entries periodically -setInterval(() => { - const now = Date.now(); - for (const [ip, record] of rateLimitStore.entries()) { - if (now > record.resetTime) { - rateLimitStore.delete(ip); - } - } -}, 60000); - -/** - * POST /api/analytics - Track an analytics event - */ -export async function POST(request: NextRequest) { - try { - // Get IP for rate limiting (Cloudflare-aware) - const ip = - request.headers.get("cf-connecting-ip") || - request.headers.get("x-forwarded-for")?.split(",")[0] || - request.headers.get("x-real-ip") || - "unknown"; - - // Rate limit check - if (!checkRateLimit(ip)) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const body = await request.json(); - const { event_type, ...eventData } = body; - - // Validate event type - if (!event_type || !VALID_EVENT_TYPES.includes(event_type)) { - return NextResponse.json( - { error: "Invalid event type" }, - { status: 400 } - ); - } - - // Get session for user ID (hashed for privacy) - const session = await getServerSession(authOptions); - let hashedUserId: string | undefined; - if (session?.user?.id) { - // Hash the user ID for privacy - hashedUserId = crypto - .createHash("sha256") - .update(session.user.id + process.env.NEXTAUTH_SECRET) - .digest("hex") - .slice(0, 16); - } - - // Get country from Cloudflare header - const country = request.headers.get("cf-ipcountry") || undefined; - - // Build the event - const event: AnalyticsEvent = { - event_type, - timestamp: new Date(), - session_id: eventData.session_id, - user_id: hashedUserId, - page_path: sanitizePath(eventData.page_path), - referrer: sanitizeUrl(eventData.referrer), - template_id: eventData.template_id, - template_name: sanitizeString(eventData.template_name, 200), - template_category: eventData.template_category, - platform: eventData.platform, - search_query: sanitizeString(eventData.search_query, 200), - search_results_count: - parseInt(eventData.search_results_count) || undefined, - wizard_step: eventData.wizard_step, - wizard_step_number: parseInt(eventData.wizard_step_number) || undefined, - feature_name: eventData.feature_name, - error_message: sanitizeString(eventData.error_message, 500), - user_agent: request.headers.get("user-agent") || undefined, - country, - properties: eventData.properties, - }; - - // Track the event (non-blocking) - trackEvent(event); - - return NextResponse.json({ success: true }, { status: 200 }); - } catch (error) { - console.error("Analytics error:", error); - // Don't expose errors to client - return NextResponse.json({ success: true }, { status: 200 }); - } -} - -// Sanitization helpers -function sanitizePath(path?: string): string | undefined { - if (!path) return undefined; - // Only allow valid URL paths - if (!/^\/[a-zA-Z0-9\-_\/\[\]]*$/.test(path)) return undefined; - return path.slice(0, 500); -} - -function sanitizeUrl(url?: string): string | undefined { - if (!url) return undefined; - try { - const parsed = new URL(url); - // Only allow http/https - if (!["http:", "https:"].includes(parsed.protocol)) return undefined; - return url.slice(0, 500); - } catch { - return undefined; - } -} - -function sanitizeString( - str?: string, - maxLen: number = 200 -): string | undefined { - if (!str) return undefined; - // Remove any potential XSS/injection - return str.replace(/[<>'"]/g, "").slice(0, maxLen); -} - diff --git a/src/app/api/blueprints/[id]/route.ts b/src/app/api/blueprints/[id]/route.ts index 838e4612..923320fb 100644 --- a/src/app/api/blueprints/[id]/route.ts +++ b/src/app/api/blueprints/[id]/route.ts @@ -109,6 +109,19 @@ export async function GET( // If not purchased AND not owner, hide the content if (isPaid && !hasPurchased && !isOwner) { + // Extract variables from content so users can see what's customizable + const variableRegex = /\[\[([A-Z_][A-Z0-9_]*)(?:\|([^\]]*))?\]\]/g; + const variables: Array<{ name: string; defaultVal?: string }> = []; + const seen = new Set(); + let match; + const content = templateWithShowcase.content || ""; + while ((match = variableRegex.exec(content)) !== null) { + if (!seen.has(match[1])) { + seen.add(match[1]); + variables.push({ name: match[1], defaultVal: match[2] || undefined }); + } + } + return NextResponse.json({ ...templateWithShowcase, content: null, // Hide content @@ -121,6 +134,8 @@ export async function GET( (templateWithShowcase.content && templateWithShowcase.content.length > 500 ? "\n\n... [Purchase to view full content]" : ""), + // Show variables even for unpurchased templates + variables: variables.length > 0 ? variables : undefined, }); } diff --git a/src/app/blueprints/[id]/page.tsx b/src/app/blueprints/[id]/page.tsx index 38ab9f53..e1ff82ff 100644 --- a/src/app/blueprints/[id]/page.tsx +++ b/src/app/blueprints/[id]/page.tsx @@ -28,10 +28,7 @@ import { Logo } from "@/components/logo"; import { UserMenu } from "@/components/user-menu"; import { ThemeToggle } from "@/components/theme-toggle"; import { Footer } from "@/components/footer"; -import { - trackTemplateView, - trackTemplateFavorite, -} from "@/lib/analytics/client"; + // Platform info const platformInfo: Record = { @@ -75,7 +72,7 @@ interface TemplateData { type?: string; // AGENTS_MD, CURSOR_COMMAND, CLAUDE_COMMAND, etc. targetPlatform?: string; compatibleWith?: string[]; - variables?: Record; + variables?: Record | Array<{ name: string; defaultVal?: string }>; sensitiveFields?: Record< string, { label: string; required: boolean; placeholder?: string } @@ -144,8 +141,6 @@ export default function BlueprintDetailPage() { if (res.ok) { const data = await res.json(); setBlueprint(data); - // Track blueprint view - trackTemplateView(data.id, data.name, data.category); } else { router.push("/blueprints"); } @@ -189,8 +184,6 @@ export default function BlueprintDetailPage() { if (res.ok) { const data = await res.json(); setIsFavorited(data.favorited); - // Track favorite action - trackTemplateFavorite(params.id as string, data.favorited); // Update local like count if (blueprint) { setBlueprint({ @@ -806,31 +799,58 @@ export default function BlueprintDetailPage() { {blueprint.isPaid && !blueprint.hasPurchased ? ( -
- {/* Blurred preview */} -
-                    {blueprint.preview}
-                  
- {/* Overlay */} -
- -

- Full content is locked. Purchase to access. -

- + <> +
+ {/* Blurred preview */} +
+                      {blueprint.preview}
+                    
+ {/* Overlay */} +
+ +

+ Full content is locked. Purchase to access. +

+ +
-
+ {/* Show customizable variables even for locked templates */} + {Array.isArray(blueprint.variables) && blueprint.variables.length > 0 && ( +
+

+ Customizable Variables ({blueprint.variables.length}) +

+

+ This template includes variables you can customize after purchase: +

+
+ {blueprint.variables.map((v) => ( + + {v.name} + {v.defaultVal && ( + = {v.defaultVal} + )} + + ))} +
+
+ )} + ) : (
@@ -898,7 +918,7 @@ export default function BlueprintDetailPage() {
             name: selectedVersion ? `${blueprint.name} (v${selectedVersion})` : blueprint.name,
             description: blueprint.description,
             content: selectedVersionContent || blueprint.content || "",
-            variables: blueprint.variables,
+            variables: Array.isArray(blueprint.variables) ? undefined : blueprint.variables,
             sensitiveFields: blueprint.sensitiveFields,
             targetPlatform: blueprint.targetPlatform || blueprint.type,
             compatibleWith: blueprint.compatibleWith,
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 33494212..6c5a0148 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,7 +3,7 @@ import { Inter, JetBrains_Mono } from "next/font/google";
 import Script from "next/script";
 import { ThemeProvider } from "@/components/providers/theme-provider";
 import { SessionProvider } from "@/components/providers/session-provider";
-import { AnalyticsProvider } from "@/components/providers/analytics-provider";
+
 import { SentryProvider } from "@/components/providers/sentry-provider";
 import { Toaster } from "@/components/ui/sonner";
 import { CookieBanner } from "@/components/cookie-banner";
@@ -140,7 +140,7 @@ export default function RootLayout({
             disableTransitionOnChange
           >
             
-              {children}
+              {children}
             
             
             
diff --git a/src/app/wizard/page.tsx b/src/app/wizard/page.tsx
index 4c45985b..0f9aa0d9 100644
--- a/src/app/wizard/page.tsx
+++ b/src/app/wizard/page.tsx
@@ -283,6 +283,12 @@ const AI_BEHAVIOR_RULES = [
   { id: "run_tests_before_commit", label: "Run Tests Before Commit", description: "Ensure tests pass before committing", recommended: true },
   { id: "follow_existing_patterns", label: "Follow Existing Patterns", description: "Match the codebase's existing style", recommended: true },
   { id: "ask_before_large_refactors", label: "Ask Before Large Refactors", description: "Confirm before significant changes", recommended: true },
+  // Burke Holland-inspired rules
+  { id: "code_for_llms", label: "Code for LLMs", description: "Optimize for LLM reasoning: flat patterns, minimal abstractions" },
+  { id: "self_improving", label: "Self-Improving Config", description: "AI updates this config when it learns project patterns" },
+  { id: "verify_work", label: "Always Verify Work", description: "Run tests and check builds before returning control", recommended: true },
+  { id: "terminal_management", label: "Terminal Management", description: "Reuse existing terminals, close unused ones" },
+  { id: "check_docs_first", label: "Check Docs First", description: "Check docs via MCP before assuming API knowledge" },
 ];
 
 // ═══════════════════════════════════════════════════════════════
@@ -684,6 +690,12 @@ type WizardConfig = {
   explanationVerbosity: string; // concise, balanced, detailed
   accessibilityFocus: boolean;
   performanceFocus: boolean;
+  mcpServers: string; // Comma-separated list of MCP servers (e.g., "filesystem, github, postgres")
+  attemptWorkarounds: boolean; // Whether AI should attempt workarounds when stuck
+  serverAccess: boolean; // Whether project requires server login
+  sshKeyPath: string; // Custom SSH key path (empty = default)
+  manualDeployment: boolean; // Whether deployment is manual (no CI/CD)
+  deploymentMethod: string; // portainer, docker_compose, kubernetes, bare_metal
   importantFiles: string[];
   importantFilesOther: string;
   enableAutoUpdate: boolean;
@@ -845,11 +857,17 @@ function WizardPageContent() {
     containerRegistryOther: "",
     dockerImageNames: "",
     registryUsername: "",
-    aiBehaviorRules: ["always_debug_after_build", "check_logs_after_build", "run_tests_before_commit", "follow_existing_patterns", "ask_before_large_refactors"],
+    aiBehaviorRules: ["always_debug_after_build", "check_logs_after_build", "run_tests_before_commit", "follow_existing_patterns", "ask_before_large_refactors", "verify_work"],
     planModeFrequency: "complex_tasks",
     explanationVerbosity: "balanced",
     accessibilityFocus: false,
     performanceFocus: false,
+    mcpServers: "",
+    attemptWorkarounds: true,
+    serverAccess: false,
+    sshKeyPath: "",
+    manualDeployment: false,
+    deploymentMethod: "",
     importantFiles: [],
     importantFilesOther: "",
     enableAutoUpdate: false,
@@ -1490,6 +1508,12 @@ function WizardPageContent() {
       
       // AI behavior
       aiBehaviorRules: config.aiBehaviorRules,
+      mcpServers: config.mcpServers,
+      attemptWorkarounds: config.attemptWorkarounds,
+      serverAccess: config.serverAccess,
+      sshKeyPath: config.sshKeyPath,
+      manualDeployment: config.manualDeployment,
+      deploymentMethod: config.deploymentMethod,
       importantFiles: config.importantFiles,
       importantFilesOther: config.importantFilesOther,
       enableAutoUpdate: config.enableAutoUpdate,
@@ -2732,6 +2756,19 @@ ${syncCommands}
                   onAccessibilityFocusChange={(v) => setConfig({ ...config, accessibilityFocus: v })}
                   performanceFocus={config.performanceFocus}
                   onPerformanceFocusChange={(v) => setConfig({ ...config, performanceFocus: v })}
+                  mcpServers={config.mcpServers}
+                  onMcpServersChange={(v) => setConfig({ ...config, mcpServers: v })}
+                  attemptWorkarounds={config.attemptWorkarounds}
+                  onAttemptWorkaroundsChange={(v) => setConfig({ ...config, attemptWorkarounds: v })}
+                  serverAccess={config.serverAccess}
+                  onServerAccessChange={(v) => setConfig({ ...config, serverAccess: v })}
+                  sshKeyPath={config.sshKeyPath}
+                  onSshKeyPathChange={(v) => setConfig({ ...config, sshKeyPath: v })}
+                  manualDeployment={config.manualDeployment}
+                  onManualDeploymentChange={(v) => setConfig({ ...config, manualDeployment: v })}
+                  deploymentMethod={config.deploymentMethod}
+                  onDeploymentMethodChange={(v) => setConfig({ ...config, deploymentMethod: v })}
+                  hasCicd={(config.cicd?.length ?? 0) > 0}
                   importantFiles={config.importantFiles}
                   importantFilesOther={config.importantFilesOther}
                   onImportantFilesToggle={(v) => toggleArrayValue("importantFiles", v)}
@@ -5348,6 +5385,19 @@ function StepAIBehavior({
   onAccessibilityFocusChange,
   performanceFocus,
   onPerformanceFocusChange,
+  mcpServers,
+  onMcpServersChange,
+  attemptWorkarounds,
+  onAttemptWorkaroundsChange,
+  serverAccess,
+  onServerAccessChange,
+  sshKeyPath,
+  onSshKeyPathChange,
+  manualDeployment,
+  onManualDeploymentChange,
+  deploymentMethod,
+  onDeploymentMethodChange,
+  hasCicd,
   importantFiles,
   importantFilesOther,
   onImportantFilesToggle,
@@ -5371,6 +5421,19 @@ function StepAIBehavior({
   onAccessibilityFocusChange: (v: boolean) => void;
   performanceFocus: boolean;
   onPerformanceFocusChange: (v: boolean) => void;
+  mcpServers: string;
+  onMcpServersChange: (v: string) => void;
+  attemptWorkarounds: boolean;
+  onAttemptWorkaroundsChange: (v: boolean) => void;
+  serverAccess: boolean;
+  onServerAccessChange: (v: boolean) => void;
+  sshKeyPath: string;
+  onSshKeyPathChange: (v: string) => void;
+  manualDeployment: boolean;
+  onManualDeploymentChange: (v: boolean) => void;
+  deploymentMethod: string;
+  onDeploymentMethodChange: (v: string) => void;
+  hasCicd: boolean;
   importantFiles: string[];
   importantFilesOther: string;
   onImportantFilesToggle: (v: string) => void;
@@ -5538,6 +5601,84 @@ function StepAIBehavior({
           checked={performanceFocus}
           onChange={onPerformanceFocusChange}
         />
+        
+      
+ + {/* MCP Servers */} +
+

πŸ”Œ MCP Servers

+

+ List any Model Context Protocol servers you have configured. The AI will know to use them when relevant. +

+ onMcpServersChange(e.target.value)} + placeholder="e.g. filesystem, github, postgres, docker" + className="mt-2 w-full rounded-md border bg-background px-3 py-2 text-sm" + /> +
+ + {/* Server Access */} +
+

πŸ–₯️ Infrastructure

+
+
+ onServerAccessChange(e.target.checked)} + className="h-4 w-4 rounded border-gray-300" + /> + +
+ {serverAccess && ( + onSshKeyPathChange(e.target.value)} + placeholder="SSH key path (leave empty for default ~/.ssh/)" + className="w-full rounded-md border bg-background px-3 py-2 text-sm" + /> + )} + {!hasCicd && ( + <> +
+ onManualDeploymentChange(e.target.checked)} + className="h-4 w-4 rounded border-gray-300" + /> + +
+ {manualDeployment && ( + + )} + + )} +
{/* Important Files to Read First */} diff --git a/src/components/providers/analytics-provider.tsx b/src/components/providers/analytics-provider.tsx deleted file mode 100644 index 42d59eed..00000000 --- a/src/components/providers/analytics-provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { usePageTracking, setupErrorTracking } from "@/lib/analytics/client"; - -interface AnalyticsProviderProps { - children: React.ReactNode; -} - -export function AnalyticsProvider({ children }: AnalyticsProviderProps) { - // Track page views on route changes - usePageTracking(); - - // Set up error tracking on mount - useEffect(() => { - setupErrorTracking(); - }, []); - - return <>{children}; -} - diff --git a/src/components/template-download-modal.tsx b/src/components/template-download-modal.tsx index 6c6f77b4..13891cd7 100644 --- a/src/components/template-download-modal.tsx +++ b/src/components/template-download-modal.tsx @@ -20,7 +20,7 @@ import { ChevronUp, } from "lucide-react"; import Link from "next/link"; -import { trackTemplateDownload } from "@/lib/analytics/client"; + import { parseVariablesWithDefaults, detectDuplicateVariableDefaults, type DuplicateVariableDefault } from "@/lib/file-generator"; import { PLATFORMS } from "@/lib/platforms"; @@ -320,10 +320,7 @@ export function TemplateDownloadModal({ // Track the download if template has an ID if (template.id) { - // Track in ClickHouse for real-time analytics - trackTemplateDownload(template.id, isCommand ? `command-${commandExportTarget}` : selectedPlatform, template.name); - - // Also track in PostgreSQL for denormalized counts + // Track in PostgreSQL for denormalized counts try { await fetch(`/api/blueprints/${template.id}/download`, { method: "POST", diff --git a/src/lib/analytics/clickhouse.ts b/src/lib/analytics/clickhouse.ts deleted file mode 100644 index 7965e698..00000000 --- a/src/lib/analytics/clickhouse.ts +++ /dev/null @@ -1,485 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/** - * ClickHouse Analytics Client for LynxPrompt - * - * This module handles all analytics event tracking using ClickHouse. - * Events are batched and sent asynchronously to minimize performance impact. - * - * Data tracked (all privacy-respecting, no PII): - * - Page views - * - Template interactions (view, download, favorite) - * - Search queries (anonymized) - * - Wizard funnel progression - * - Feature usage - * - Errors - */ - -// Event types we track -export type AnalyticsEventType = - | "page_view" - | "template_view" - | "template_download" - | "template_favorite" - | "template_search" - | "wizard_step" - | "wizard_complete" - | "wizard_abandon" - | "feature_use" - | "error"; - -export interface AnalyticsEvent { - event_type: AnalyticsEventType; - timestamp?: Date; - session_id?: string; - user_id?: string; // Hashed, not actual user ID - // Page/route info - page_path?: string; - referrer?: string; - // Template info - template_id?: string; - template_name?: string; - template_category?: string; - platform?: string; - // Search info - search_query?: string; - search_results_count?: number; - // Wizard info - wizard_step?: string; - wizard_step_number?: number; - // Feature info - feature_name?: string; - // Error info - error_message?: string; - error_stack?: string; - // Context - user_agent?: string; - country?: string; - // Custom properties - properties?: Record; -} - -// ClickHouse connection config -const CLICKHOUSE_CONFIG = { - host: process.env.CLICKHOUSE_HOST || "localhost", - port: process.env.CLICKHOUSE_PORT || "8123", - database: process.env.CLICKHOUSE_DB || "lynxprompt_analytics", - user: process.env.CLICKHOUSE_USER || "default", - password: process.env.CLICKHOUSE_PASSWORD || "", -}; - -// Check if analytics is enabled -const isAnalyticsEnabled = () => { - return ( - process.env.NODE_ENV === "production" && - process.env.CLICKHOUSE_HOST && - process.env.CLICKHOUSE_PASSWORD - ); -}; - -// Event buffer for batching -let eventBuffer: AnalyticsEvent[] = []; -const BATCH_SIZE = 10; -const FLUSH_INTERVAL_MS = 5000; - -// Flush timer -let flushTimer: NodeJS.Timeout | null = null; - -/** - * Initialize ClickHouse tables (run once on startup) - */ -export async function initializeClickHouse(): Promise { - if (!isAnalyticsEnabled()) { - console.log("ClickHouse analytics disabled (missing config)"); - return; - } - - const createTableSQL = ` - CREATE TABLE IF NOT EXISTS events ( - event_id UUID DEFAULT generateUUIDv4(), - event_type LowCardinality(String), - timestamp DateTime64(3) DEFAULT now64(3), - session_id String, - user_id String, - page_path String, - referrer String, - template_id String, - template_name String, - template_category LowCardinality(String), - platform LowCardinality(String), - search_query String, - search_results_count UInt32, - wizard_step LowCardinality(String), - wizard_step_number UInt8, - feature_name LowCardinality(String), - error_message String, - user_agent String, - country LowCardinality(String), - properties String -- JSON string for flexible properties - ) ENGINE = MergeTree() - PARTITION BY toYYYYMM(timestamp) - ORDER BY (event_type, timestamp) - TTL toDateTime(timestamp) + INTERVAL 1 YEAR - SETTINGS index_granularity = 8192 - `; - - try { - await executeQuery(createTableSQL); - console.log("ClickHouse events table initialized"); - - // Create materialized views for common queries - await createMaterializedViews(); - } catch (error) { - console.error("Failed to initialize ClickHouse:", error); - } -} - -/** - * Create materialized views for fast analytics queries - */ -async function createMaterializedViews(): Promise { - // Daily template stats - const dailyTemplateStats = ` - CREATE MATERIALIZED VIEW IF NOT EXISTS mv_daily_template_stats - ENGINE = SummingMergeTree() - PARTITION BY toYYYYMM(date) - ORDER BY (date, template_id, event_type) - AS SELECT - toDate(timestamp) as date, - template_id, - event_type, - count() as count - FROM events - WHERE template_id != '' AND event_type IN ('template_view', 'template_download', 'template_favorite') - GROUP BY date, template_id, event_type - `; - - // Hourly platform usage - const hourlyPlatformStats = ` - CREATE MATERIALIZED VIEW IF NOT EXISTS mv_hourly_platform_stats - ENGINE = SummingMergeTree() - PARTITION BY toYYYYMM(hour) - ORDER BY (hour, platform) - AS SELECT - toStartOfHour(timestamp) as hour, - platform, - count() as downloads - FROM events - WHERE event_type = 'template_download' AND platform != '' - GROUP BY hour, platform - `; - - // Search query aggregation - const searchQueryStats = ` - CREATE MATERIALIZED VIEW IF NOT EXISTS mv_search_queries - ENGINE = SummingMergeTree() - PARTITION BY toYYYYMM(date) - ORDER BY (date, search_query) - AS SELECT - toDate(timestamp) as date, - lower(search_query) as search_query, - count() as count, - avg(search_results_count) as avg_results - FROM events - WHERE event_type = 'template_search' AND search_query != '' - GROUP BY date, search_query - `; - - // Wizard funnel - const wizardFunnel = ` - CREATE MATERIALIZED VIEW IF NOT EXISTS mv_wizard_funnel - ENGINE = SummingMergeTree() - PARTITION BY toYYYYMM(date) - ORDER BY (date, wizard_step_number) - AS SELECT - toDate(timestamp) as date, - wizard_step, - wizard_step_number, - count() as count - FROM events - WHERE event_type = 'wizard_step' - GROUP BY date, wizard_step, wizard_step_number - `; - - try { - await executeQuery(dailyTemplateStats); - await executeQuery(hourlyPlatformStats); - await executeQuery(searchQueryStats); - await executeQuery(wizardFunnel); - console.log("ClickHouse materialized views created"); - } catch (error) { - // Views might already exist, that's OK - console.log("Materialized views setup complete"); - } -} - -/** - * Execute a ClickHouse query - */ -async function executeQuery(query: string): Promise { - const url = `http://${CLICKHOUSE_CONFIG.host}:${CLICKHOUSE_CONFIG.port}/?database=${CLICKHOUSE_CONFIG.database}`; - - const response = await fetch(url, { - method: "POST", - headers: { - "X-ClickHouse-User": CLICKHOUSE_CONFIG.user, - "X-ClickHouse-Key": CLICKHOUSE_CONFIG.password, - "Content-Type": "text/plain", - }, - body: query, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`ClickHouse query failed: ${error}`); - } - - return response.text(); -} - -/** - * Track an analytics event - */ -export function trackEvent(event: AnalyticsEvent): void { - if (!isAnalyticsEnabled()) return; - - // Add timestamp if not provided - event.timestamp = event.timestamp || new Date(); - - // Add to buffer - eventBuffer.push(event); - - // Flush if buffer is full - if (eventBuffer.length >= BATCH_SIZE) { - flushEvents(); - } - - // Set up flush timer if not already running - if (!flushTimer) { - flushTimer = setTimeout(() => { - flushEvents(); - flushTimer = null; - }, FLUSH_INTERVAL_MS); - } -} - -/** - * Flush buffered events to ClickHouse - */ -async function flushEvents(): Promise { - if (eventBuffer.length === 0) return; - - const eventsToFlush = [...eventBuffer]; - eventBuffer = []; - - // Clear timer - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } - - try { - // Build INSERT query - const values = eventsToFlush - .map((e) => { - // Format timestamp for ClickHouse DateTime64(3) - remove 'Z' suffix - const ts = (e.timestamp || new Date()).toISOString().replace('Z', '').replace('T', ' '); - return `( - '${e.event_type}', - '${ts}', - '${e.session_id || ""}', - '${e.user_id || ""}', - '${escapeSql(e.page_path || "")}', - '${escapeSql(e.referrer || "")}', - '${e.template_id || ""}', - '${escapeSql(e.template_name || "")}', - '${e.template_category || ""}', - '${e.platform || ""}', - '${escapeSql(e.search_query || "")}', - ${e.search_results_count || 0}, - '${e.wizard_step || ""}', - ${e.wizard_step_number || 0}, - '${e.feature_name || ""}', - '${escapeSql(e.error_message || "")}', - '${escapeSql(e.user_agent || "")}', - '${e.country || ""}', - '${e.properties ? JSON.stringify(e.properties) : "{}"}' - )`; - }) - .join(","); - - const insertSQL = ` - INSERT INTO events ( - event_type, timestamp, session_id, user_id, page_path, referrer, - template_id, template_name, template_category, platform, - search_query, search_results_count, wizard_step, wizard_step_number, - feature_name, error_message, user_agent, country, properties - ) VALUES ${values} - `; - - await executeQuery(insertSQL); - } catch (error) { - console.error("Failed to flush analytics events:", error); - // Put events back in buffer for retry (limit to prevent memory issues) - if (eventBuffer.length < 100) { - eventBuffer = [...eventsToFlush, ...eventBuffer]; - } - } -} - -/** - * Escape SQL string - */ -function escapeSql(str: string): string { - return str.replace(/'/g, "\\'").replace(/\\/g, "\\\\").slice(0, 1000); -} - -// ============================================================================= -// QUERY HELPERS - For retrieving analytics data -// ============================================================================= - -/** - * Get trending templates (most viewed/downloaded in last N days) - */ -export async function getTrendingTemplates( - days: number = 7, - limit: number = 10 -): Promise> { - if (!isAnalyticsEnabled()) return []; - - const query = ` - SELECT - template_id, - countIf(event_type = 'template_view') as views, - countIf(event_type = 'template_download') as downloads - FROM events - WHERE - template_id != '' - AND timestamp >= now() - INTERVAL ${days} DAY - AND event_type IN ('template_view', 'template_download') - GROUP BY template_id - ORDER BY downloads DESC, views DESC - LIMIT ${limit} - FORMAT JSON - `; - - try { - const result = await executeQuery(query); - const parsed = JSON.parse(result); - return parsed.data || []; - } catch { - return []; - } -} - -/** - * Get popular search queries - */ -export async function getPopularSearches( - days: number = 30, - limit: number = 20 -): Promise> { - if (!isAnalyticsEnabled()) return []; - - const query = ` - SELECT - lower(search_query) as query, - count() as count - FROM events - WHERE - event_type = 'template_search' - AND search_query != '' - AND timestamp >= now() - INTERVAL ${days} DAY - GROUP BY query - ORDER BY count DESC - LIMIT ${limit} - FORMAT JSON - `; - - try { - const result = await executeQuery(query); - const parsed = JSON.parse(result); - return parsed.data || []; - } catch { - return []; - } -} - -/** - * Get platform distribution - */ -export async function getPlatformStats( - days: number = 30 -): Promise> { - if (!isAnalyticsEnabled()) return []; - - const query = ` - SELECT - platform, - count() as downloads, - round(count() * 100.0 / sum(count()) OVER (), 2) as percentage - FROM events - WHERE - event_type = 'template_download' - AND platform != '' - AND timestamp >= now() - INTERVAL ${days} DAY - GROUP BY platform - ORDER BY downloads DESC - FORMAT JSON - `; - - try { - const result = await executeQuery(query); - const parsed = JSON.parse(result); - return parsed.data || []; - } catch { - return []; - } -} - -/** - * Get wizard funnel drop-off rates - */ -export async function getWizardFunnel(days: number = 30): Promise< - Array<{ - step: string; - step_number: number; - count: number; - drop_off_rate: number; - }> -> { - if (!isAnalyticsEnabled()) return []; - - const query = ` - WITH funnel AS ( - SELECT - wizard_step as step, - wizard_step_number as step_number, - count() as count - FROM events - WHERE - event_type = 'wizard_step' - AND timestamp >= now() - INTERVAL ${days} DAY - GROUP BY wizard_step, wizard_step_number - ORDER BY wizard_step_number - ) - SELECT - step, - step_number, - count, - round((1 - count / lagInFrame(count, 1, count) OVER (ORDER BY step_number)) * 100, 2) as drop_off_rate - FROM funnel - FORMAT JSON - `; - - try { - const result = await executeQuery(query); - const parsed = JSON.parse(result); - return parsed.data || []; - } catch { - return []; - } -} - -// Export for server-side initialization -export { isAnalyticsEnabled }; - diff --git a/src/lib/analytics/client.ts b/src/lib/analytics/client.ts deleted file mode 100644 index 48245f2a..00000000 --- a/src/lib/analytics/client.ts +++ /dev/null @@ -1,274 +0,0 @@ -"use client"; - -/** - * Client-side Analytics for LynxPrompt - * - * Lightweight tracking that sends events to our ClickHouse backend. - * All tracking respects user privacy - no PII is collected. - */ - -import { useEffect, useRef, useCallback } from "react"; -import { usePathname } from "next/navigation"; - -// Generate a session ID (persisted for the browser session) -function getSessionId(): string { - if (typeof window === "undefined") return ""; - - let sessionId = sessionStorage.getItem("lynx_session_id"); - if (!sessionId) { - sessionId = crypto.randomUUID(); - sessionStorage.setItem("lynx_session_id", sessionId); - } - return sessionId; -} - -// Check if tracking should be enabled -function isTrackingEnabled(): boolean { - if (typeof window === "undefined") return false; - - // Respect Do Not Track - if (navigator.doNotTrack === "1") return false; - - // Only track in production - if (process.env.NODE_ENV !== "production") return false; - - return true; -} - -/** - * Send an analytics event to the server - */ -async function sendEvent( - eventType: string, - data: Record = {} -): Promise { - if (!isTrackingEnabled()) return; - - try { - await fetch("/api/analytics", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - event_type: eventType, - session_id: getSessionId(), - page_path: window.location.pathname, - referrer: document.referrer || undefined, - ...data, - }), - // Use keepalive to ensure events are sent even on page unload - keepalive: true, - }); - } catch { - // Silently fail - analytics should never break the app - } -} - -// ============================================================================= -// TRACKING FUNCTIONS - Use these throughout the app -// ============================================================================= - -/** - * Track a page view - */ -export function trackPageView(path?: string): void { - sendEvent("page_view", { page_path: path || window.location.pathname }); -} - -/** - * Track template view - */ -export function trackTemplateView( - templateId: string, - templateName?: string, - category?: string -): void { - sendEvent("template_view", { - template_id: templateId, - template_name: templateName, - template_category: category, - }); -} - -/** - * Track template download - */ -export function trackTemplateDownload( - templateId: string, - platform: string, - templateName?: string -): void { - sendEvent("template_download", { - template_id: templateId, - platform, - template_name: templateName, - }); -} - -/** - * Track template favorite/unfavorite - */ -export function trackTemplateFavorite( - templateId: string, - isFavorited: boolean -): void { - sendEvent("template_favorite", { - template_id: templateId, - properties: { favorited: isFavorited }, - }); -} - -/** - * Track search query - */ -export function trackSearch(query: string, resultsCount: number): void { - sendEvent("template_search", { - search_query: query, - search_results_count: resultsCount, - }); -} - -/** - * Track wizard step progression - */ -export function trackWizardStep(stepId: string, stepNumber: number): void { - sendEvent("wizard_step", { - wizard_step: stepId, - wizard_step_number: stepNumber, - }); -} - -/** - * Track wizard completion - */ -export function trackWizardComplete(platforms: string[]): void { - sendEvent("wizard_complete", { - properties: { platforms: platforms.join(",") }, - }); -} - -/** - * Track wizard abandonment - */ -export function trackWizardAbandon(lastStep: string, stepNumber: number): void { - sendEvent("wizard_abandon", { - wizard_step: lastStep, - wizard_step_number: stepNumber, - }); -} - -/** - * Track feature usage - */ -export function trackFeatureUse(featureName: string): void { - sendEvent("feature_use", { feature_name: featureName }); -} - -/** - * Track client-side error - */ -export function trackError(message: string, stack?: string): void { - sendEvent("error", { - error_message: message, - error_stack: stack?.slice(0, 500), - }); -} - -// ============================================================================= -// REACT HOOKS -// ============================================================================= - -/** - * Hook to automatically track page views on route changes - */ -export function usePageTracking(): void { - const pathname = usePathname(); - const lastPathRef = useRef(""); - - useEffect(() => { - // Only track if the path actually changed - if (pathname && pathname !== lastPathRef.current) { - lastPathRef.current = pathname; - trackPageView(pathname); - } - }, [pathname]); -} - -/** - * Hook for tracking template views (with deduplication) - */ -export function useTemplateTracking( - templateId: string, - templateName?: string, - category?: string -): void { - const trackedRef = useRef(false); - - useEffect(() => { - if (!trackedRef.current && templateId) { - trackedRef.current = true; - trackTemplateView(templateId, templateName, category); - } - }, [templateId, templateName, category]); -} - -/** - * Hook for wizard step tracking - */ -export function useWizardTracking(): { - trackStep: (stepId: string, stepNumber: number) => void; - trackComplete: (platforms: string[]) => void; - trackAbandon: () => void; -} { - const lastStepRef = useRef<{ id: string; number: number } | null>(null); - - const trackStep = useCallback((stepId: string, stepNumber: number) => { - lastStepRef.current = { id: stepId, number: stepNumber }; - trackWizardStep(stepId, stepNumber); - }, []); - - const trackComplete = useCallback((platforms: string[]) => { - trackWizardComplete(platforms); - }, []); - - const trackAbandon = useCallback(() => { - if (lastStepRef.current) { - trackWizardAbandon(lastStepRef.current.id, lastStepRef.current.number); - } - }, []); - - // Track abandonment on unmount - useEffect(() => { - return () => { - // Only track abandon if wizard wasn't completed - // (This is called on any unmount, so the calling component should - // set a flag when complete to prevent false positives) - }; - }, []); - - return { trackStep, trackComplete, trackAbandon }; -} - -// ============================================================================= -// ERROR BOUNDARY HELPER -// ============================================================================= - -/** - * Set up global error tracking - */ -export function setupErrorTracking(): void { - if (typeof window === "undefined") return; - - // Track unhandled errors - window.addEventListener("error", (event) => { - trackError(event.message, event.error?.stack); - }); - - // Track unhandled promise rejections - window.addEventListener("unhandledrejection", (event) => { - trackError( - event.reason?.message || "Unhandled promise rejection", - event.reason?.stack - ); - }); -} - diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts deleted file mode 100644 index 0c001a7e..00000000 --- a/src/lib/analytics/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Server-side analytics (ClickHouse) -export { - initializeClickHouse, - trackEvent, - getTrendingTemplates, - getPopularSearches, - getPlatformStats, - getWizardFunnel, - isAnalyticsEnabled, - type AnalyticsEvent, - type AnalyticsEventType, -} from "./clickhouse"; - -// Client-side analytics -export { - trackPageView, - trackTemplateView, - trackTemplateDownload, - trackTemplateFavorite, - trackSearch, - trackWizardStep, - trackWizardComplete, - trackWizardAbandon, - trackFeatureUse, - trackError, - usePageTracking, - useTemplateTracking, - useWizardTracking, - setupErrorTracking, -} from "./client"; - diff --git a/src/lib/file-generator.ts b/src/lib/file-generator.ts index f6897135..cc66f8ff 100644 --- a/src/lib/file-generator.ts +++ b/src/lib/file-generator.ts @@ -115,6 +115,12 @@ interface WizardConfig { saveAllPreferences?: boolean; security?: SecurityConfig; useGitWorktrees?: boolean; // Use git worktrees for parallel AI agent sessions + mcpServers?: string; // Comma-separated list of MCP servers (e.g., "filesystem, github, postgres") + attemptWorkarounds?: boolean; // Whether AI should attempt workarounds when stuck + serverAccess?: boolean; // Whether project requires server login + sshKeyPath?: string; // Custom SSH key path (empty = default) + manualDeployment?: boolean; // Whether deployment is manual (no CI/CD) + deploymentMethod?: string; // portainer, docker_compose, kubernetes, bare_metal } // Security configuration (FREE tier) @@ -613,7 +619,7 @@ function generateCursorRules(config: WizardConfig, user: UserProfile): string { const authorName = user.displayName || user.name || "Developer"; lines.push(`- **Author**: ${bpVar(bp, "AUTHOR_NAME", authorName)}`); if (user.persona) { - lines.push(`- **Developer Type**: ${user.persona.replace(/_/g, " ")}`); + lines.push(`- **Developer Background**: ${user.persona.replace(/_/g, " ")} β€” adapt responses to this domain expertise`); } lines.push(`- **Experience Level**: ${user.skillLevel.charAt(0).toUpperCase() + user.skillLevel.slice(1)}`); lines.push(""); @@ -688,6 +694,48 @@ function generateCursorRules(config: WizardConfig, user: UserProfile): string { const ruleText = getRuleDescription(rule); if (ruleText) lines.push(`- ${ruleText}`); }); + if (config.attemptWorkarounds === true) { + lines.push("- When stuck, **attempt creative workarounds** before asking for help"); + } else if (config.attemptWorkarounds === false) { + lines.push("- When stuck, **stop and ask** rather than attempting workarounds"); + } + lines.push(""); + } + + // MCP Servers + if (config.mcpServers) { + const servers = config.mcpServers.split(",").map((s: string) => s.trim()).filter(Boolean); + if (servers.length > 0) { + lines.push("## MCP Servers"); + lines.push(""); + lines.push("The developer has these MCP (Model Context Protocol) servers available. Use them when relevant:"); + lines.push(""); + servers.forEach((server: string) => { + lines.push(`- ${server}`); + }); + lines.push(""); + } + } + + // Server access & deployment + if (config.serverAccess || config.manualDeployment) { + lines.push("## Infrastructure"); + lines.push(""); + if (config.serverAccess) { + const keyInfo = config.sshKeyPath + ? `SSH key: \`${config.sshKeyPath}\`` + : "SSH key in default location (~/.ssh/)"; + lines.push(`- **Server access**: via SSH. ${keyInfo}`); + } + if (config.manualDeployment && config.deploymentMethod) { + const methods: Record = { + portainer: "Portainer (GitOps stacks)", + docker_compose: "Docker Compose (manual)", + kubernetes: "Kubernetes (kubectl apply)", + bare_metal: "Bare metal (direct deployment)", + }; + lines.push(`- **Deployment**: ${methods[config.deploymentMethod] || config.deploymentMethod}`); + } lines.push(""); } @@ -1361,7 +1409,7 @@ function generateAgentsMd(config: WizardConfig, user: UserProfile): string { lines.push("- Be concise and direct"); } if (user.persona) { - lines.push(`- Developer context: ${user.persona.replace(/_/g, " ")}`); + lines.push(`- Developer background: ${user.persona.replace(/_/g, " ")} β€” adapt responses to this domain expertise`); } lines.push(`- Skill level: ${user.skillLevel.charAt(0).toUpperCase() + user.skillLevel.slice(1)}`); lines.push(""); @@ -1938,7 +1986,7 @@ function generateAiderConfig(config: WizardConfig, user: UserProfile): string { lines.push(`# Author: ${bpVar(bp, "AUTHOR_NAME", authorName)}`); lines.push(`# Skill Level: ${user.skillLevel.charAt(0).toUpperCase() + user.skillLevel.slice(1)}`); if (user.persona) { - lines.push(`# Developer Type: ${user.persona.replace(/_/g, " ")}`); + lines.push(`# Developer Background: ${user.persona.replace(/_/g, " ")} - adapt responses to this domain expertise`); } lines.push("#"); lines.push("# Communication Style:"); @@ -2737,7 +2785,7 @@ function generateTabnineConfig(config: WizardConfig, user: UserProfile): string lines.push(`# Author: ${authorName}`); lines.push(`# Skill Level: ${user.skillLevel.charAt(0).toUpperCase() + user.skillLevel.slice(1)}`); if (user.persona) { - lines.push(`# Developer Type: ${user.persona.replace(/_/g, " ")}`); + lines.push(`# Developer Background: ${user.persona.replace(/_/g, " ")} - adapt responses to this domain expertise`); } lines.push("#"); } @@ -3482,7 +3530,7 @@ function generateVoidConfig(config: WizardConfig, user: UserProfile): string { const authorName = user.displayName || user.name || "Developer"; rulesParts.push(`- **Author**: ${bpVar(bp, "AUTHOR_NAME", authorName)}`); if (user.persona) { - rulesParts.push(`- **Type**: ${user.persona.replace(/_/g, " ")}`); + rulesParts.push(`- **Developer Background**: ${user.persona.replace(/_/g, " ")} β€” adapt responses to this domain expertise`); } rulesParts.push(`- **Experience**: ${user.skillLevel.charAt(0).toUpperCase() + user.skillLevel.slice(1)}`); rulesParts.push(""); @@ -3947,6 +3995,12 @@ function getRuleDescription(ruleId: string): string { check_for_security_issues: "Review code for security vulnerabilities", document_complex_logic: "Add documentation for complex implementations", use_conventional_commits: "Follow conventional commit message format", + // Burke Holland-inspired rules + code_for_llms: "Optimize code for LLM reasoning: prefer flat/explicit patterns, minimal abstractions, structured logging, and linear control flow", + self_improving: "When you learn new project patterns or conventions, suggest updates to this configuration file", + verify_work: "Always verify your work before returning: run tests, check builds, confirm changes work as expected", + terminal_management: "Reuse existing terminals when possible. Close terminals you no longer need", + check_docs_first: "Always check documentation (via MCP or project docs) before assuming knowledge about APIs or libraries", }; return rules[ruleId] || ruleId; }