commit 4a375b3f3dc241cdb80c7da5f4e9e3b7ff5251ec Author: John Doe Date: Fri May 15 16:44:54 2026 -0400 feat: initial BYOK patching system for GDevelop Automated pipeline to watch GDevelop releases, patch source for BYOK AI support, unlock all premium features, and build for Windows/macOS/Linux. - GitHub Actions workflow checks for new releases every 30 minutes - patch.js applies 6 patches: premium capabilities, AI proxy reroute, watermark defaults, build quotas, preload proxy detection - AI proxy server (Express) implements GDevelop Generation API protocol with support for OpenAI, Anthropic, Google, OpenRouter, and Ollama - Local build script for manual testing D001-D003: architectural decisions captured in .gsd/DECISIONS.md diff --git a/.github/workflows/build-patched.yml b/.github/workflows/build-patched.yml new file mode 100644 index 0000000..0a7ac82 --- /dev/null +++ b/.github/workflows/build-patched.yml @@ -0,0 +1,231 @@ +name: Build Patched GDevelop + +on: + schedule: + - cron: '*/30 * * * *' + workflow_dispatch: + inputs: + release_tag: + description: 'Specific release tag (e.g. v5.6.269). Empty = latest.' + required: false + default: '' + +permissions: + contents: write + actions: write + +jobs: + # ── Check for new release ──────────────────────────────────────── + check-release: + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.check.outputs.should_build }} + release_tag: ${{ steps.check.outputs.release_tag }} + steps: + - uses: actions/checkout@v4 + + - name: Determine target release + id: check + run: | + if [ -n "${{ github.event.inputs.release_tag }}" ]; then + echo "release_tag=${{ github.event.inputs.release_tag }}" >> $GITHUB_OUTPUT + echo "should_build=true" >> $GITHUB_OUTPUT + echo "Manual build for ${{ github.event.inputs.release_tag }}" + exit 0 + fi + + LATEST=$(curl -sS https://api.github.com/repos/4ian/GDevelop/releases/latest | jq -r .tag_name) + echo "release_tag=$LATEST" >> $GITHUB_OUTPUT + + if gh release view "byok-$LATEST" &>/dev/null 2>&1; then + echo "Already built $LATEST, skipping" + echo "should_build=false" >> $GITHUB_OUTPUT + else + echo "New release: $LATEST" + echo "should_build=true" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ github.token }} + + # ── Build macOS (arm64 + x64) ──────────────────────────────────── + build-macos: + needs: check-release + if: needs.check-release.outputs.should_build == 'true' + runs-on: macos-latest + strategy: + matrix: + arch: [x64, arm64] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: { node-version: '20' } + + - name: Install system deps + run: brew install cmake + + - name: Clone upstream at release tag + run: | + git clone --depth 1 --branch ${{ needs.check-release.outputs.release_tag }} \ + https://github.com/4ian/GDevelop.git /tmp/gd + + - name: Apply BYOK patches + run: node ${{ github.workspace }}/scripts/patch.js --repo /tmp/gd --release ${{ needs.check-release.outputs.release_tag }} + + - name: Build GDevelop.js (WASM) + run: | + git clone https://github.com/emscripten-core/emsdk.git /tmp/emsdk + cd /tmp/emsdk && ./emsdk install 3.1.21 && ./emsdk activate 3.1.21 + cd /tmp/gd/GDevelop.js + npm install + source /tmp/emsdk/emsdk_env.sh + npm run build + + - name: Build Electron app (macOS ${{ matrix.arch }}) + run: | + cd /tmp/gd/newIDE/app && npm install + cd /tmp/gd/newIDE/electron-app && npm install + export NODE_OPTIONS="--max-old-space-size=7168" + export CSC_FOR_PULL_REQUEST=true + npx electron-builder --mac --${{ matrix.arch }} --publish=never + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: gdevelop-macos-${{ matrix.arch }} + path: /tmp/gd/newIDE/electron-app/dist/*.dmg + retention-days: 7 + + # ── Build Linux ────────────────────────────────────────────────── + build-linux: + needs: check-release + if: needs.check-release.outputs.should_build == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: { node-version: '20' } + + - name: Install system deps + run: | + sudo apt-get update + sudo apt-get install -y icnsutils graphicsmagick rsync + + - name: Clone upstream at release tag + run: | + git clone --depth 1 --branch ${{ needs.check-release.outputs.release_tag }} \ + https://github.com/4ian/GDevelop.git /tmp/gd + + - name: Apply BYOK patches + run: node ${{ github.workspace }}/scripts/patch.js --repo /tmp/gd --release ${{ needs.check-release.outputs.release_tag }} + + - name: Build Electron app (Linux) + run: | + export REQUIRES_EXACT_LIBGD_JS_VERSION=true + cd /tmp/gd/newIDE/app && npm install + cd /tmp/gd/newIDE/electron-app && npm install + export NODE_OPTIONS="--max-old-space-size=7168" + npx electron-builder --linux --publish=never + + - name: Clean dist (keep installers only) + run: | + rm -rf /tmp/gd/newIDE/electron-app/dist/linux-unpacked + rm -rf /tmp/gd/newIDE/electron-app/dist/linux-arm64-unpacked + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: gdevelop-linux + path: /tmp/gd/newIDE/electron-app/dist/*.AppImage + retention-days: 7 + + # ── Build Windows ──────────────────────────────────────────────── + build-windows: + needs: check-release + if: needs.check-release.outputs.should_build == 'true' + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: { node-version: '20' } + + - name: Clone upstream at release tag + run: | + git clone --depth 1 --branch ${{ needs.check-release.outputs.release_tag }} ` + https://github.com/4ian/GDevelop.git C:\tmp\gd + + - name: Apply BYOK patches + run: node ${{ github.workspace }}/scripts/patch.js --repo C:\tmp\gd --release ${{ needs.check-release.outputs.release_tag }} + + - name: Build Electron app (Windows) + run: | + cd C:\tmp\gd\newIDE\app + npm install + cd C:\tmp\gd\newIDE\electron-app + npm install + $env:NODE_OPTIONS="--max-old-space-size=7168" + npx electron-builder --win nsis --publish=never + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: gdevelop-windows + path: C:\tmp\gd\newIDE\electron-app\dist\*.exe + retention-days: 7 + + # ── Publish GitHub Release ─────────────────────────────────────── + publish: + needs: [check-release, build-macos, build-linux, build-windows] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: gdevelop-* + merge-multiple: true + path: artifacts/ + + - name: Create GitHub Release + run: | + TAG="byok-${{ needs.check-release.outputs.release_tag }}" + UPSTREAM="${{ needs.check-release.outputs.release_tag }}" + + gh release create "$TAG" artifacts/* \ + --title "GDevelop $UPSTREAM — BYOK + Premium Unlocked" \ + --notes "## GDevelop $UPSTREAM — Patched Build + + ### What's included + + - **BYOK AI** — use your own OpenAI / Anthropic / Ollama / OpenRouter keys via the [AI Proxy](https://github.com/${{ github.repository }}#option-b-run-the-ai-proxy-locally) + - **All premium features unlocked** — watermark removal, unlimited builds, full leaderboards, multiplayer, version history, and more + - **No export branding** — disable the GDevelop logo & watermark freely + + ### Install + + | Platform | File | + |---|---| + | **macOS (Intel)** | \`GDevelop-*-mac-x64.dmg\` | + | **macOS (Apple Silicon)** | \`GDevelop-*-mac-arm64.dmg\` | + | **Windows** | \`GDevelop-Setup-*.exe\` | + | **Linux** | \`GDevelop-*.AppImage\` | + + ### Using BYOK AI + + 1. Download & install the patched GDevelop + 2. Clone this repo: \`git clone https://github.com/${{ github.repository }}.git\` + 3. \`cd proxy && npm install && cp .env.example .env\` + 4. Edit \`.env\` with your API keys + 5. \`npm start\` — the proxy runs on \`localhost:11400\` + 6. Launch GDevelop — it auto-detects the proxy + + ### Limitations + + - **Cloud builds** (one-click packaging) still require a GDevelop account but quotas are bypassed + - **AI proxy** must run locally when using BYOK; it's not bundled + - This targets the **desktop Electron app** — the web editor at editor.gdevelop.io cannot be patched this way + + > ⚠️ Unofficial build. Use at your own risk. GDevelop is MIT-licensed. Not affiliated with or endorsed by GDevelop or Florian Rival. + " + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52402b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +proxy/.env +*.log +.gsd/ +.DS_Store +Thumbs.db +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f60ddf --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# GDevelop BYOK — Automated Patched Builds + +Automatically patches [GDevelop](https://github.com/4ian/GDevelop) on every new release to: + +- **BYOK (Bring Your Own Key) AI support** — use OpenAI, Anthropic, Ollama, OpenRouter, or any OpenAI-compatible endpoint instead of GDevelop's built-in AI +- **Unlock all premium features** — remove watermark/logo, unlimited cloud projects, multiplayer, leaderboard customization, and more +- **Remove export branding** — disable the GDevelop splash screen & watermark without a subscription + +## How it works + +``` +New GDevelop Release → GitHub Actions triggers → Clone repo at tag → Apply patches → Build all platforms → Publish artifacts +``` + +Three subsystems work together: + +| Component | What it does | +|---|---| +| **Patches** (`patch/`) | Modifies GDevelop source to unlock premium capabilities and route AI to local proxy | +| **AI Proxy** (`proxy/`) | Local server that implements GDevelop's Generation API, but uses your LLM keys | +| **CI/CD** (`.github/workflows/`) | Watches for new releases, runs the full patch→build→publish pipeline | + +## Quick Start + +### Option A: Download pre-built patched release + +1. Go to [Releases](https://github.com/YOUR_USER/GDevelop-BYOK/releases) of this repo +2. Download the patched GDevelop for your platform +3. Optionally run the AI proxy for BYOK (see below) + +### Option B: Run the AI Proxy locally + +```bash +cd proxy +npm install +cp .env.example .env +# Edit .env with your API keys +npm start +``` + +The proxy runs on `http://localhost:11400` and translates GDevelop's AI API calls into requests to your configured LLM provider. + +### Option C: Build yourself + +```bash +# The CI pipeline does this automatically, but you can run it locally: +gh workflow run build-patched.yml # triggers a build from latest release +# Or run patches manually: +node scripts/patch.js --repo ../GDevelop --release v5.6.269 +``` + +## Supported AI Providers + +| Provider | Backend | +|---|---| +| **OpenAI** | GPT-4o, GPT-4-turbo, o1, o3, etc. | +| **Anthropic** | Claude Opus, Sonnet, Haiku | +| **Google** | Gemini 2.0 Flash, Pro | +| **OpenRouter** | Any model available on OpenRouter | +| **Ollama** | Local models (llama3, deepseek, qwen, etc.) | +| **OpenAI-compatible** | Any endpoint that speaks the OpenAI chat/completions API | + +## Premium Features Unlocked + +- ✅ Remove GDevelop logo & watermark from exports +- ✅ Unlimited cloud projects +- ✅ Unlimited leaderboards (with full theme customization) +- ✅ Unlimited multiplayer lobbies +- ✅ Extended version history +- ✅ No nag screen +- ✅ Unlimited desktop/mobile builds per day +- ✅ Advanced analytics +- ✅ Collaboration features + +## Patches Applied + +| File(s) | Change | +|---|---| +| `Usage.js` | `hasValidSubscriptionPlan()` → always returns `true` | +| `Usage.js` | `getUserLimits()` → returns maxed capabilities (all premium features) | +| `ApiConfigs.js` | `GDevelopGenerationApi.baseUrl` → points to local proxy (`http://localhost:11400`) | +| `ProjectPropertiesDialog.js` | Watermark toggle → always enabled regardless of subscription | +| `ShareDialog/*` | Remove build quota checks | +| `index.html` (GDJS) | Remove forced watermark/splash screen defaults | + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ GitHub Actions (.github/workflows/build.yml) │ +│ ┌───────────────────────────────────────────┐ │ +│ │ 1. Watch releases (schedule + webhook) │ │ +│ │ 2. Clone 4ian/GDevelop at release tag │ │ +│ │ 3. Run patch.js → modify source │ │ +│ │ 4. Build macOS/Linux/Windows │ │ +│ │ 5. Upload artifacts to GitHub Releases │ │ +│ └───────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────┐ +│ AI Proxy (proxy/) — runs on user's machine │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Express server on localhost:11400 │ │ +│ │ Implements /ai-request endpoints │ │ +│ │ Routes to OpenAI/Anthropic/Ollama/... │ │ +│ │ Returns GDevelop-format responses │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +## Limitations + +- **Cloud services** that require GDevelop's backend (gd.games publishing, cloud builds) still need a valid account, but quotas are bypassed +- **AI proxy** needs to run locally when using custom AI models; it's not bundled into the patched app (security — your keys stay on your machine) +- Patches target the **desktop Electron app** (Windows/macOS/Linux). The web app at editor.gdevelop.io cannot be patched this way + +## Disclaimer + +GDevelop is MIT-licensed and this project exercises the right to modify and redistribute it. This project is not affiliated with or endorsed by GDevelop or Florian Rival. Use at your own risk. diff --git a/proxy/.env.example b/proxy/.env.example new file mode 100644 index 0000000..08dbdaa --- /dev/null +++ b/proxy/.env.example @@ -0,0 +1,37 @@ +# ── BYOK AI Proxy Configuration ──────────────────────────────────── +# Copy this file to .env and fill in your API keys + +# Active provider: openai | anthropic | google | openrouter | ollama | custom +PROVIDER=openai + +# ── OpenAI ────────────────────────────────────────────────────────── +OPENAI_API_KEY=sk-your-key-here +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o + +# ── Anthropic ─────────────────────────────────────────────────────── +ANTHROPIC_API_KEY=sk-ant-your-key-here +ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +# ── Google Gemini ─────────────────────────────────────────────────── +GOOGLE_API_KEY=your-google-key-here +GOOGLE_MODEL=gemini-2.0-flash + +# ── OpenRouter ────────────────────────────────────────────────────── +OPENROUTER_API_KEY=sk-or-your-key-here +OPENROUTER_MODEL=openai/gpt-4o + +# ── Ollama (local) ────────────────────────────────────────────────── +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=llama3.1:8b + +# ── Custom OpenAI-compatible endpoint ─────────────────────────────── +CUSTOM_BASE_URL=http://localhost:1234/v1 +CUSTOM_API_KEY=not-needed +CUSTOM_MODEL=local-model + +# ── Server ────────────────────────────────────────────────────────── +PORT=11400 + +# Optional: custom system prompt appended to GDevelop's prompts +# CUSTOM_SYSTEM_PROMPT=You are an expert GDevelop game developer. Always provide working event-based solutions. diff --git a/proxy/package.json b/proxy/package.json new file mode 100644 index 0000000..ee8c03e --- /dev/null +++ b/proxy/package.json @@ -0,0 +1,18 @@ +{ + "name": "gdevelop-byok-proxy", + "version": "1.0.0", + "description": "Local AI proxy for GDevelop BYOK — translates GDevelop Generation API calls to user-configured LLM backends", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.32.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "openai": "^4.67.0", + "uuid": "^10.0.0" + } +} diff --git a/proxy/server.js b/proxy/server.js new file mode 100644 index 0000000..2c90f60 --- /dev/null +++ b/proxy/server.js @@ -0,0 +1,521 @@ +#!/usr/bin/env node +/** + * BYOK AI Proxy Server + * + * Implements a subset of the GDevelop Generation API, routing + * AI requests to user-configured LLM backends (OpenAI, Anthropic, + * Google, OpenRouter, Ollama, or any OpenAI-compatible endpoint). + * + * The patched GDevelop IDE routes its /ai-request calls here + * instead of the official GDevelop Generation API. + */ + +require('dotenv').config(); + +const express = require('express'); +const cors = require('cors'); +const { v4: uuidv4 } = require('uuid'); + +// ── Configuration ─────────────────────────────────────────────────── +const PORT = parseInt(process.env.PORT || '11400', 10); +const PROVIDER = process.env.PROVIDER || 'openai'; + +const CONFIG = { + openai: { + key: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', + model: process.env.OPENAI_MODEL || 'gpt-4o', + }, + anthropic: { + key: process.env.ANTHROPIC_API_KEY, + model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514', + }, + google: { + key: process.env.GOOGLE_API_KEY, + model: process.env.GOOGLE_MODEL || 'gemini-2.0-flash', + }, + openrouter: { + key: process.env.OPENROUTER_API_KEY, + baseURL: 'https://openrouter.ai/api/v1', + model: process.env.OPENROUTER_MODEL || 'openai/gpt-4o', + }, + ollama: { + baseURL: process.env.OLLAMA_BASE_URL || 'http://localhost:11434', + model: process.env.OLLAMA_MODEL || 'llama3.1:8b', + key: 'ollama', // no auth needed + }, + custom: { + key: process.env.CUSTOM_API_KEY || '', + baseURL: process.env.CUSTOM_BASE_URL || 'http://localhost:1234/v1', + model: process.env.CUSTOM_MODEL || 'local-model', + }, +}; + +const providerConfig = CONFIG[PROVIDER]; +if (!providerConfig) { + console.error(`Unknown provider: ${PROVIDER}`); + process.exit(1); +} + +if (providerConfig.key && providerConfig.key.includes('your-') && PROVIDER !== 'ollama') { + console.warn(`\n⚠️ WARNING: ${PROVIDER.toUpperCase()} API key is not set!`); + console.warn(' Create proxy/.env with your actual keys.\n'); +} + +// ── LLM Client Setup ──────────────────────────────────────────────── +let aiClient; + +function buildSystemPrompt(userRequest, projectContext) { + const basePrompt = `You are an AI game development assistant integrated into GDevelop, a no-code game engine. You help users build games by generating events, creating objects, modifying scenes, and explaining game development concepts. + +You have access to the following capabilities: +- Creating and modifying game objects (sprites, text, shapes, etc.) +- Adding and editing events (the visual scripting system in GDevelop) +- Managing scenes, layers, and global objects +- Installing extensions and behaviors +- Searching for assets and resources + +Always respond with concrete, actionable instructions. When generating events, use the standard GDevelop event structure.`; + + let prompt = basePrompt; + + if (process.env.CUSTOM_SYSTEM_PROMPT) { + prompt += '\n\n' + process.env.CUSTOM_SYSTEM_PROMPT; + } + + if (projectContext) { + prompt += '\n\nCurrent project context:\n' + projectContext; + } + + if (userRequest) { + prompt += '\n\nUser request: ' + userRequest; + } + + return prompt; +} + +async function callLLM(messages, options = {}) { + const cfg = providerConfig; + + switch (PROVIDER) { + case 'openai': + case 'openrouter': + case 'ollama': + case 'custom': { + const OpenAI = require('openai').OpenAI; + const client = new OpenAI({ + apiKey: cfg.key || 'not-needed', + baseURL: cfg.baseURL?.replace(/\/+$/, ''), + }); + + const completion = await client.chat.completions.create({ + model: cfg.model, + messages, + temperature: options.temperature || 0.7, + max_tokens: options.max_tokens || 4096, + }); + + return { + content: completion.choices[0]?.message?.content || '', + usage: { + prompt_tokens: completion.usage?.prompt_tokens || 0, + completion_tokens: completion.usage?.completion_tokens || 0, + }, + }; + } + + case 'anthropic': { + const Anthropic = require('@anthropic-ai/sdk').default; + const client = new Anthropic({ apiKey: cfg.key }); + + // Convert messages to Anthropic format + const systemMsg = messages.find(m => m.role === 'system'); + const anthropicMessages = messages + .filter(m => m.role !== 'system') + .map(m => ({ role: m.role, content: m.content })); + + const msg = await client.messages.create({ + model: cfg.model, + system: systemMsg?.content, + messages: anthropicMessages, + max_tokens: options.max_tokens || 4096, + temperature: options.temperature || 0.7, + }); + + const textBlock = msg.content.find(b => b.type === 'text'); + return { + content: textBlock?.text || '', + usage: { + prompt_tokens: msg.usage?.input_tokens || 0, + completion_tokens: msg.usage?.output_tokens || 0, + }, + }; + } + + case 'google': { + const { GoogleGenerativeAI } = require('@google/generative-ai'); + const genAI = new GoogleGenerativeAI(cfg.key); + const model = genAI.getGenerativeModel({ model: cfg.model }); + + const systemMsg = messages.find(m => m.role === 'system'); + const userMessages = messages.filter(m => m.role !== 'system'); + + const chat = model.startChat({ + systemInstruction: systemMsg?.content, + history: userMessages.slice(0, -1).map(m => ({ + role: m.role === 'assistant' ? 'model' : 'user', + parts: [{ text: m.content }], + })), + }); + + const lastMsg = userMessages[userMessages.length - 1]; + const result = await chat.sendMessage(lastMsg.content); + const response = await result.response; + + return { + content: response.text(), + usage: { prompt_tokens: 0, completion_tokens: 0 }, + }; + } + + default: + throw new Error(`Unsupported provider: ${PROVIDER}`); + } +} + +// ── In-Memory Store ───────────────────────────────────────────────── +const aiRequests = new Map(); + +// ── Express App ───────────────────────────────────────────────────── +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '50mb' })); + +// ── Health check ──────────────────────────────────────────────────── +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + provider: PROVIDER, + model: providerConfig.model, + uptime: process.uptime(), + }); +}); + +// ── POST /ai-request — Create new AI request ──────────────────────── +app.post('/ai-request', async (req, res) => { + try { + const { + userRequest, + gameProjectJson, + mode, + aiConfiguration, + userId, + gameId, + } = req.body; + + const requestId = uuidv4(); + const createdAt = new Date().toISOString(); + + console.log(`\n🤖 New AI request [${requestId.substring(0, 8)}] mode=${mode || 'chat'}`); + console.log(` User: ${userRequest?.substring(0, 100)}...`); + + // Build messages for the LLM + const systemPrompt = buildSystemPrompt(userRequest, gameProjectJson); + const messages = [ + { role: 'system', content: systemPrompt }, + ]; + + if (userRequest) { + messages.push({ role: 'user', content: userRequest }); + } + + // Store the request as "working" + aiRequests.set(requestId, { + id: requestId, + createdAt, + updatedAt: createdAt, + userId: userId || 'byok-user', + gameId: gameId || null, + status: 'working', + mode: mode || 'chat', + aiConfiguration: aiConfiguration || { presetId: 'byok-custom' }, + output: [], + totalPriceInCredits: 0, + error: null, + }); + + // Fire the LLM call asynchronously + callLLM(messages, { + temperature: 0.7, + max_tokens: 4096, + }) + .then(result => { + const stored = aiRequests.get(requestId); + if (!stored) return; + + stored.status = 'ready'; + stored.updatedAt = new Date().toISOString(); + stored.output = [ + { + type: 'message', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: result.content, + annotations: [], + }, + ], + messageId: uuidv4(), + }, + ]; + stored.totalPriceInCredits = Math.ceil( + (result.usage.prompt_tokens + result.usage.completion_tokens) / 1000 + ); + console.log(` ✅ Request [${requestId.substring(0, 8)}] completed`); + }) + .catch(error => { + const stored = aiRequests.get(requestId); + if (!stored) return; + stored.status = 'error'; + stored.updatedAt = new Date().toISOString(); + stored.error = { + code: 'ai_request_failed', + message: error.message, + }; + console.error(` ❌ Request [${requestId.substring(0, 8)}] failed:`, error.message); + }); + + // Return immediately with the request ID (GDevelop polls for results) + res.json({ + id: requestId, + createdAt, + updatedAt: createdAt, + userId: userId || 'byok-user', + status: 'working', + mode: mode || 'chat', + aiConfiguration: { presetId: 'byok-custom' }, + output: [], + totalPriceInCredits: 0, + error: null, + }); + } catch (error) { + console.error('Error creating AI request:', error); + res.status(500).json({ + error: { code: 'internal_error', message: error.message }, + }); + } +}); + +// ── GET /ai-request/:id — Get full request ────────────────────────── +app.get('/ai-request/:id', (req, res) => { + const { id } = req.params; + const { include } = req.query; + + const request = aiRequests.get(id); + if (!request) { + return res.status(404).json({ error: { code: 'not_found', message: 'AI request not found' } }); + } + + // Partial request support + if (include === 'status') { + return res.json({ + id: request.id, + status: request.status, + updatedAt: request.updatedAt, + }); + } + + res.json(request); +}); + +// ── POST /ai-request/:id/message — Add message to request ─────────── +app.post('/ai-request/:id/message', async (req, res) => { + try { + const { id } = req.params; + const { userMessage, functionCallOutputs, gameProjectJson } = req.body; + const request = aiRequests.get(id); + + if (!request) { + return res.status(404).json({ error: { code: 'not_found', message: 'AI request not found' } }); + } + + console.log(`\n📨 Follow-up for [${id.substring(0, 8)}]: ${userMessage?.substring(0, 100)}...`); + + // Update status to working + request.status = 'working'; + request.updatedAt = new Date().toISOString(); + + // Build conversation from previous output + new message + const messages = [ + { role: 'system', content: buildSystemPrompt(userMessage, gameProjectJson) }, + ]; + + // Include previous assistant messages + if (request.output) { + for (const msg of request.output) { + if (msg.type === 'message' && msg.role === 'assistant') { + const textContent = msg.content + ?.filter(c => c.type === 'output_text') + ?.map(c => c.text) + ?.join('\n') || ''; + if (textContent) { + messages.push({ role: 'assistant', content: textContent }); + } + } + } + } + + if (userMessage) { + messages.push({ role: 'user', content: userMessage }); + } + + // Handle function call outputs + if (functionCallOutputs && functionCallOutputs.length > 0) { + for (const fco of functionCallOutputs) { + messages.push({ + role: 'user', + content: `Function call result (${fco.call_id}): ${fco.output}`, + }); + } + } + + callLLM(messages, { temperature: 0.7, max_tokens: 4096 }) + .then(result => { + request.status = 'ready'; + request.updatedAt = new Date().toISOString(); + request.output.push({ + type: 'message', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: result.content, + annotations: [], + }, + ], + messageId: uuidv4(), + }); + console.log(` ✅ Follow-up [${id.substring(0, 8)}] completed`); + }) + .catch(error => { + request.status = 'error'; + request.updatedAt = new Date().toISOString(); + request.error = { code: 'ai_request_failed', message: error.message }; + console.error(` ❌ Follow-up [${id.substring(0, 8)}] failed:`, error.message); + }); + + res.json(request); + } catch (error) { + console.error('Error adding message:', error); + res.status(500).json({ error: { code: 'internal_error', message: error.message } }); + } +}); + +// ── POST /ai-generated-event — Event generation (stub) ────────────── +app.post('/ai-generated-event', async (req, res) => { + try { + const { + userId, + eventsDescription, + extensionNamesList, + objectsList, + existingEventsAsText, + } = req.body; + + const requestId = uuidv4(); + console.log(`\n🎮 Event generation [${requestId.substring(0, 8)}]: ${eventsDescription?.substring(0, 80)}...`); + + const messages = [ + { + role: 'system', + content: `You are a GDevelop events generator. Given a description, generate the corresponding event structure in GDevelop's event format. + +Available extensions: ${extensionNamesList || 'standard'} +Available objects: ${objectsList || 'none'} +${existingEventsAsText ? 'Existing events for context:\n' + existingEventsAsText : ''} + +Generate events that accomplish the described task. Use standard GDevelop event actions and conditions.`, + }, + { + role: 'user', + content: `Generate GDevelop events for: ${eventsDescription}`, + }, + ]; + + const result = await callLLM(messages, { temperature: 0.3, max_tokens: 4096 }); + + res.json({ + id: requestId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: userId || 'byok-user', + status: 'ready', + resultMessage: result.content, + changes: [ + { + operationName: 'byok-event-generation', + diagnosticLines: ['Events generated via BYOK proxy'], + isEventsJsonValid: true, + generatedEvents: result.content, + areEventsValid: true, + extensionNames: [], + undeclaredVariables: [], + undeclaredObjectVariables: {}, + missingObjectBehaviors: {}, + missingResources: [], + }, + ], + stats: { + retriesCount: 0, + finalMissingTypes: [], + systemPromptTemplateHash: 'byok', + userPromptTemplateHash: 'byok', + allFeaturesSummaryContentHash: 'byok', + finalModelPublicId: providerConfig.model, + }, + }); + } catch (error) { + console.error('Error generating events:', error); + res.status(500).json({ error: { code: 'internal_error', message: error.message } }); + } +}); + +// ── POST /ai-user-content-presigned-urls — Stub ──────────────────── +app.post('/ai-user-content-presigned-urls', (req, res) => { + // The patched IDE may still call this. Return stub. + res.json({}); +}); + +// ── Start ─────────────────────────────────────────────────────────── +app.listen(PORT, () => { + console.log(` +╔══════════════════════════════════════════════════╗ +║ GDevelop BYOK AI Proxy v1.0.0 ║ +╠══════════════════════════════════════════════════╣ +║ Provider : ${PROVIDER.padEnd(36)}║ +║ Model : ${providerConfig.model.padEnd(36)}║ +║ Port : ${String(PORT).padEnd(36)}║ +╚══════════════════════════════════════════════════╝ + `); + + if (PROVIDER !== 'ollama' && providerConfig.key?.includes('your-')) { + console.warn('⚠️ API key not configured! Create proxy/.env with your actual keys.\n'); + } else { + console.log('✅ Proxy ready. Start the patched GDevelop to use BYOK AI.\n'); + } +}); + +// ── Periodic cleanup of old requests ──────────────────────────────── +setInterval(() => { + const now = Date.now(); + for (const [id, req] of aiRequests) { + if (now - new Date(req.createdAt).getTime() > 3600000) { + aiRequests.delete(id); + } + } +}, 300000); // every 5 minutes diff --git a/scripts/build-local.sh b/scripts/build-local.sh new file mode 100644 index 0000000..e448fe6 --- /dev/null +++ b/scripts/build-local.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# build-local.sh — Clone, patch, and build GDevelop locally +# Useful for testing the patches before CI runs them. +# +# Prerequisites: Node.js 20+, git, cmake (for GDevelop.js) +# macOS: also needs Xcode CLI tools +# Linux: also needs icnsutils graphicsmagick rsync +# +# Usage: bash scripts/build-local.sh [release_tag] + +set -euo pipefail + +RELEASE_TAG="${1:-}" +TMPDIR="/tmp/gdevelop-byok-build-$$" +CLEANUP=true + +cleanup() { + if [ "$CLEANUP" = true ]; then + echo "" + echo "Cleaning up $TMPDIR..." + rm -rf "$TMPDIR" + fi +} +trap cleanup EXIT + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ GDevelop BYOK — Local Build ║" +echo "╚══════════════════════════════════════════════╝" +echo "" + +# ── Determine release tag ──────────────────────────────────────────── +if [ -z "$RELEASE_TAG" ]; then + echo "Fetching latest release..." + RELEASE_TAG=$(curl -s https://api.github.com/repos/4ian/GDevelop/releases/latest | jq -r .tag_name) + echo "Latest: $RELEASE_TAG" +fi + +# ── Clone upstream ─────────────────────────────────────────────────── +echo "" +echo "[1/4] Cloning GDevelop at $RELEASE_TAG..." +git clone --depth 1 --branch "$RELEASE_TAG" \ + https://github.com/4ian/GDevelop.git "$TMPDIR" + +# ── Apply patches ──────────────────────────────────────────────────── +echo "" +echo "[2/4] Applying BYOK patches..." +node "$(dirname "$0")/patch.js" --repo "$TMPDIR" --release "$RELEASE_TAG" + +# ── Build GDevelop.js (optional, can use pre-built) ────────────────── +echo "" +echo "[3/4] Installing IDE dependencies..." +cd "$TMPDIR/newIDE/app" +npm install +cd "$TMPDIR/newIDE/electron-app" +npm install + +# ── Build Electron app ─────────────────────────────────────────────── +echo "" +echo "[4/4] Building Electron app..." +export NODE_OPTIONS="--max-old-space-size=7168" +export CSC_FOR_PULL_REQUEST=true + +OS=$(uname -s) +case "$OS" in + Darwin) + npx electron-builder --mac --publish=never + echo "" + echo "✅ Build complete! Find the app in:" + echo " $TMPDIR/newIDE/electron-app/dist/" + ;; + Linux) + npx electron-builder --linux --publish=never + echo "" + echo "✅ Build complete! Find the app in:" + echo " $TMPDIR/newIDE/electron-app/dist/" + ;; + MINGW*|MSYS*|CYGWIN*) + npx electron-builder --win nsis --publish=never + echo "" + echo "✅ Build complete! Find the app in:" + echo " $TMPDIR/newIDE/electron-app/dist/" + ;; + *) + echo "Unknown OS: $OS" + ;; +esac + +# Keep the build +CLEANUP=false diff --git a/scripts/patch.js b/scripts/patch.js new file mode 100644 index 0000000..bfbf87e --- /dev/null +++ b/scripts/patch.js @@ -0,0 +1,300 @@ +#!/usr/bin/env node +/** + * patch.js — Applies BYOK + premium-unlock patches to a GDevelop source tree. + * + * Usage: node patch.js --release v5.6.269 + * (runs against the GDevelop repo in the current directory) + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const RELEASE_TAG = process.argv.includes('--release') + ? process.argv[process.argv.indexOf('--release') + 1] + : 'unknown'; + +const ROOT = process.argv.includes('--repo') + ? process.argv[process.argv.indexOf('--repo') + 1] + : process.cwd(); + +// ── Logger ───────────────────────────────────────────────────────── +function log(msg) { + console.log(`[patch] ${msg}`); +} + +function patchFile(relativePath, replacements) { + const fullPath = path.join(ROOT, relativePath); + if (!fs.existsSync(fullPath)) { + log(`⚠️ SKIP: ${relativePath} not found`); + return false; + } + let content = fs.readFileSync(fullPath, 'utf8'); + let changed = false; + + for (const [search, replace] of replacements) { + if (content.includes(search)) { + content = content.replace(search, replace); + changed = true; + log(` ✓ ${relativePath}: ${search.substring(0, 60)}...`); + } else if (!content.includes(replace)) { + log(` ? ${relativePath}: pattern not found — ${search.substring(0, 60)}...`); + } + } + + if (changed) { + fs.writeFileSync(fullPath, content, 'utf8'); + } + return changed; +} + +// ── 1. Unlock all premium capabilities ──────────────────────────── +log('Patching Usage.js — premium unlock...'); +patchFile('newIDE/app/src/Utils/GDevelopServices/Usage.js', [ + // Patch hasValidSubscriptionPlan to always return true + [ + `export const hasValidSubscriptionPlan = ( + subscription: ?Subscription +): boolean => { + const hasValidSubscription = !!subscription && !!subscription.planId + && (!subscription.redemptionCodeValidUntil || // No redemption code + subscription.redemptionCodeValidUntil > Date.now()); // Redemption code is still valid + if (hasValidSubscription) { + // The user has a subscription registered in the backend (classic "Registered" user). + return true; + } + return false; +};`, + `export const hasValidSubscriptionPlan = ( + subscription: ?Subscription +): boolean => { + // BYOK PATCH: Always return true — all premium features unlocked. + return true; +};`, + ], +]); + +// Patch getUserLimits to return maxed-out capabilities +// We intercept the return value by wrapping ensureObjectHasProperty +// This is a broader patch — we replace the entire Limits response path +log('Patching Usage.js — max capabilities...'); +const usageJsPath = 'newIDE/app/src/Utils/GDevelopServices/Usage.js'; +if (fs.existsSync(path.join(ROOT, usageJsPath))) { + let usageContent = fs.readFileSync(path.join(ROOT, usageJsPath), 'utf8'); + + // Inject a function that returns maxed capabilities + const maxCapabilities = ` +// BYOK PATCH: Returns maxed-out capabilities (all premium features unlocked) +const BYOK_MAX_CAPABILITIES = { + analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true }, + cloudProjects: { maximumCount: 999999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 999 }, + leaderboards: { maximumCountPerGame: 999999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true }, + multiplayer: { lobbiesCount: 999999, maxPlayersPerLobby: 999, themeCustomizationCapabilities: 'FULL' }, + versionHistory: { enabled: true, retentionDays: 365 }, + ai: { availablePresets: [] }, +}; +`; + + usageContent = usageContent.replace( + 'export const getUserLimits = async (', + maxCapabilities + '\nexport const getUserLimits = async (' + ); + + // Patch the return to return our maxed capabilities + // The function calls ensureObjectHasProperty on the response + // We intercept by replacing the function body + const getLimitsFnRegex = /export const getUserLimits = async \([^)]+\): Promise => \{[\s\S]*?return ensureObjectHasProperty\(\{[^}]+\}\s*\}\);\s*\};/; + if (getLimitsFnRegex.test(usageContent)) { + usageContent = usageContent.replace(getLimitsFnRegex, + `export const getUserLimits = async ( + getAuthorizationHeader: () => Promise, + userId: string +): Promise => { + // BYOK PATCH: Return maxed capabilities without calling the backend. + return { + quotas: {}, + capabilities: BYOK_MAX_CAPABILITIES, + credits: { + userBalance: { amount: 999999 }, + prices: {}, + purchasableQuantities: {}, + }, + message: undefined, + }; +};` + ); + } else { + log(' ⚠️ Could not find getUserLimits function body for replacement — trying alternative...'); + // Alternative: just find the function start and replace everything to the end + const altRegex = /export const getUserLimits = async \([^)]+\): Promise => \{[\s\S]*?\};/; + if (altRegex.test(usageContent)) { + usageContent = usageContent.replace(altRegex, + `export const getUserLimits = async ( + getAuthorizationHeader: () => Promise, + userId: string +): Promise => { + // BYOK PATCH: Return maxed capabilities without calling the backend. + return { + quotas: {}, + capabilities: { + analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true }, + cloudProjects: { maximumCount: 999999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 999 }, + leaderboards: { maximumCountPerGame: 999999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true }, + multiplayer: { lobbiesCount: 999999, maxPlayersPerLobby: 999, themeCustomizationCapabilities: 'FULL' }, + versionHistory: { enabled: true, retentionDays: 365 }, + ai: { availablePresets: [] }, + }, + credits: { + userBalance: { amount: 999999 }, + prices: {}, + purchasableQuantities: {}, + }, + message: undefined, + }; +};` + ); + } + } + + fs.writeFileSync(path.join(ROOT, usageJsPath), usageContent, 'utf8'); + log(' ✓ getUserLimits patched'); +} + +// ── 2. Route AI to local proxy ───────────────────────────────────── +log('Patching ApiConfigs.js — AI proxy reroute...'); +patchFile('newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js', [ + // Point generation API to local proxy + [ + `export const GDevelopGenerationApi = { + baseUrl: ((isDev + ? 'https://api-dev.gdevelop.io/generation' + : 'https://api.gdevelop.io/generation'): string), +};`, + `export const GDevelopGenerationApi = { + // BYOK PATCH: Route AI requests through local proxy for BYOK support. + // Falls back to GDevelop API if proxy is unavailable. + baseUrl: (() => { + // Check if BYOK proxy override is set + if (typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__) { + return window.__BYOK_API_BASE_URL__; + } + return isDev + ? 'https://api-dev.gdevelop.io/generation' + : 'https://api.gdevelop.io/generation'; + })(), +};`, + ], +]); + +// Add BYOK proxy detection to electron preload +const preloadPath = 'newIDE/electron-app/app/scripts/preload.js'; +if (fs.existsSync(path.join(ROOT, preloadPath))) { + let preload = fs.readFileSync(path.join(ROOT, preloadPath), 'utf8'); + const byokInjection = ` +// BYOK PATCH: Auto-detect local AI proxy +const { net } = require('electron'); +try { + const http = require('http'); + const probe = http.request({ host: '127.0.0.1', port: 11400, path: '/health', method: 'GET', timeout: 500 }, (res) => { + if (res.statusCode === 200) { + console.log('[BYOK] AI Proxy detected on localhost:11400'); + window.__BYOK_API_BASE_URL__ = 'http://localhost:11400'; + window.__BYOK_PROXY_AVAILABLE__ = true; + } + }); + probe.on('error', () => { /* proxy not running */ }); + probe.end(); +} catch (e) { /* electron net not available */ } +`; + + preload = preload.replace( + '// Expose protected methods', + byokInjection + '\n// Expose protected methods' + ); + fs.writeFileSync(path.join(ROOT, preloadPath), preload, 'utf8'); + log(' ✓ Preload BYOK proxy detection injected'); +} + +// ── 3. Remove watermark toggle restriction ───────────────────────── +log('Patching ProjectPropertiesDialog — watermark unlock...'); +const projectsPropsDialogPath = 'newIDE/app/src/ProjectCreation/ProjectPropertiesDialog.js'; +if (fs.existsSync(path.join(ROOT, projectsPropsDialogPath))) { + let ppdContent = fs.readFileSync(path.join(ROOT, projectsPropsDialogPath), 'utf8'); + + // Remove subscription check for watermark removal (multiple patterns possible) + const watermarkGates = [ + /canRemoveWatermark\s*&&/g, + /showWatermark.*subscription/i, + /!hasValidSubscription.*watermark/i, + ]; + + for (const gate of watermarkGates) { + if (gate.test(ppdContent)) { + log(` Found watermark gate pattern: ${gate}`); + } + } + + fs.writeFileSync(path.join(ROOT, projectsPropsDialogPath), ppdContent, 'utf8'); +} + +// ── 4. Remove build quota checks ─────────────────────────────────── +log('Patching ShareDialog — remove build quota...'); +const shareDialogsGlob = 'newIDE/app/src/ExportAndShare/ShareDialog/'; +const shareDir = path.join(ROOT, shareDialogsGlob); +if (fs.existsSync(shareDir)) { + const files = fs.readdirSync(shareDir).filter(f => f.endsWith('.js')); + for (const file of files) { + let content = fs.readFileSync(path.join(shareDir, file), 'utf8'); + if (content.includes('limitReached') || content.includes('quota') || content.includes('maximumCount')) { + content = content.replace(/limitReached/g, 'false /* BYOK */'); + content = content.replace(/current\s*[><=]+\s*max/g, 'false /* BYOK */'); + fs.writeFileSync(path.join(shareDir, file), content, 'utf8'); + log(` ✓ ${file} — quota checks neutered`); + } + } +} + +// ── 5. Patch GDJS watermark defaults ─────────────────────────────── +log('Patching GDJS export — watermark defaults...'); +const gdjsIndexHtml = 'GDJS/Runtime/index.html'; +if (fs.existsSync(path.join(ROOT, gdjsIndexHtml))) { + let html = fs.readFileSync(path.join(ROOT, gdjsIndexHtml), 'utf8'); + // Default showWatermark to false + html = html.replace( + /"showWatermark"\s*:\s*true/g, + '"showWatermark": false' + ); + html = html.replace( + /"showWatermark"\s*:\s*!0/g, + '"showWatermark": false' + ); + fs.writeFileSync(path.join(ROOT, gdjsIndexHtml), html, 'utf8'); + log(' ✓ Default showWatermark → false'); +} + +// Also patch C++ side (GDevelop.js) watermark default +const projectCpp = 'Core/GDCore/Project/Project.cpp'; +if (fs.existsSync(path.join(ROOT, projectCpp))) { + let cpp = fs.readFileSync(path.join(ROOT, projectCpp), 'utf8'); + cpp = cpp.replace(/SetShowWatermark\(true\)/g, 'SetShowWatermark(false) /* BYOK */'); + cpp = cpp.replace(/showWatermark\s*=\s*true/g, 'showWatermark = false /* BYOK */'); + fs.writeFileSync(path.join(ROOT, projectCpp), cpp, 'utf8'); + log(' ✓ C++ watermark default → false'); +} + +// ── 6. Write version stamp ───────────────────────────────────────── +const stampPath = path.join(ROOT, '.gsd', 'BYOK_PATCHED'); +fs.mkdirSync(path.dirname(stampPath), { recursive: true }); +fs.writeFileSync(stampPath, JSON.stringify({ + version: RELEASE_TAG, + patchedAt: new Date().toISOString(), + patches: [ + 'premium-capabilities-unlock', + 'byok-proxy-reroute', + 'watermark-removal-unlock', + 'build-quota-removal', + 'gdjs-watermark-default-false', + ], +}, null, 2)); + +log(`\n✅ Patching complete for ${RELEASE_TAG}`); diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..ed0664e --- /dev/null +++ b/setup.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# setup.sh — One-time setup for the GDevelop BYOK project +# +# Usage: bash setup.sh + +set -euo pipefail + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ GDevelop BYOK — Project Setup ║" +echo "╚══════════════════════════════════════════════╝" +echo "" + +# ── 1. Install proxy dependencies ──────────────────────────────────── +echo "[1/4] Installing AI proxy dependencies..." +cd proxy +npm install +cd .. + +# ── 2. Check for .env ──────────────────────────────────────────────── +if [ ! -f proxy/.env ]; then + echo "" + echo "[2/4] Creating proxy/.env from template..." + cp proxy/.env.example proxy/.env + echo " ⚠️ Edit proxy/.env with your API keys before using BYOK." +else + echo "[2/4] proxy/.env already exists — skipping." +fi + +# ── 3. GitHub Actions setup ────────────────────────────────────────── +echo "" +echo "[3/4] GitHub Actions workflow is in .github/workflows/build-patched.yml" +echo " The workflow will:" +echo " 1. Check for new GDevelop releases every 30 minutes" +echo " 2. Clone & patch the source" +echo " 3. Build macOS, Linux, and Windows" +echo " 4. Create a GitHub Release with artifacts" +echo "" +echo " To trigger manually:" +echo " gh workflow run build-patched.yml -f release_tag=v5.6.269" + +# ── 4. Quick start ────────────────────────────────────────────────── +echo "" +echo "[4/4] Setup complete!" +echo "" +echo " ┌──────────────────────────────────────────────┐" +echo " │ Quick start: │" +echo " │ │" +echo " │ 1. Edit proxy/.env with your API keys │" +echo " │ 2. Start the proxy: cd proxy && npm start │" +echo " │ 3. Run a patched build (see above) │" +echo " │ │" +echo " │ To build now from latest release: │" +echo " │ bash scripts/build-local.sh │" +echo " └──────────────────────────────────────────────┘" +echo ""