import * as http from "node:http"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createDatabase } from "./db.js"; import { WHACK_CARDS, SUIT_META, getCardByNumber, drawCards, drawFourRoleSpread } from "./whack_cards.js"; import { NANO_BANANA_MODELS, SURFACE_PRESETS, buildNanoBananaPrompt, generateWithNanoBanana, isCodexConfigured } from "./nano_banana.js"; import { DEFAULT_USER_BACKGROUND, DEV_HANDOFF_TARGETS, generatePersonalizedRandomIdea, generateNameSuggestions, buildDevelopmentSpecMarkdown, executeDevelopmentHandoff } from "./idea_generator.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT_DIR = path.resolve(__dirname, ".."); const PUBLIC_DIR = path.join(ROOT_DIR, "public"); const BUNDLED_DATA_DIR = path.join(ROOT_DIR, "data"); const ACTIVE_DATA_DIR = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : BUNDLED_DATA_DIR; const DEFAULT_DB_PATH = path.join(ACTIVE_DATA_DIR, "ideas.db"); const DEFAULT_MOCKUPS_DIR = path.join(ACTIVE_DATA_DIR, "mockups"); const MIME_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon" }; function sendJson(res, statusCode, payload) { const body = JSON.stringify(payload); res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); res.end(body); } function readJsonBody(req, maxBytes = 25 * 1024 * 1024) { return new Promise((resolve, reject) => { const chunks = []; let total = 0; req.on("data", (chunk) => { total += chunk.length; if (total > maxBytes) { reject(new Error("Payload too large")); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { try { const raw = Buffer.concat(chunks).toString("utf-8"); resolve(raw ? JSON.parse(raw) : {}); } catch (err) { reject(new Error("Invalid JSON body")); } }); req.on("error", reject); }); } function serveStaticFile(res, filePath) { if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { sendJson(res, 404, { error: "File not found" }); return; } const ext = path.extname(filePath).toLowerCase(); const contentType = MIME_TYPES[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": contentType }); fs.createReadStream(filePath).pipe(res); } function ensureSeedMockupsCopied(targetMockupsDir) { fs.mkdirSync(targetMockupsDir, { recursive: true }); const bundledMockupsDir = path.join(BUNDLED_DATA_DIR, "mockups"); if (!fs.existsSync(bundledMockupsDir) || path.resolve(bundledMockupsDir) === path.resolve(targetMockupsDir)) { return; } for (const file of fs.readdirSync(bundledMockupsDir)) { const src = path.join(bundledMockupsDir, file); const dest = path.join(targetMockupsDir, file); if (!fs.existsSync(dest) && fs.statSync(src).isFile()) { fs.copyFileSync(src, dest); } } } export function createAppServer({ dbPath = DEFAULT_DB_PATH, mockupsDir = DEFAULT_MOCKUPS_DIR } = {}) { ensureSeedMockupsCopied(mockupsDir); const store = createDatabase(dbPath); const server = http.createServer(async (req, res) => { try { const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); const pathname = decodeURIComponent(parsedUrl.pathname); const method = req.method.toUpperCase(); // --- HEALTHCHECK --- if (method === "GET" && pathname === "/api/health") { sendJson(res, 200, { status: "ok", service: "sparkvault", timestamp: new Date().toISOString() }); return; } // --- API ROUTES --- // 1. GET /api/ideas if (method === "GET" && pathname === "/api/ideas") { const status = parsedUrl.searchParams.get("status") || "all"; const category = parsedUrl.searchParams.get("category") || "all"; const tag = parsedUrl.searchParams.get("tag") || "all"; const search = parsedUrl.searchParams.get("search") || ""; const hasMockup = parsedUrl.searchParams.get("hasMockup") || ""; const sort = parsedUrl.searchParams.get("sort") || "score"; const ideas = store.listIdeas({ status, category, tag, search, hasMockup, sort }); const allIdeas = store.listIdeas({}); const tagCounts = {}; const statusCounts = { spark: 0, exploring: 0, building: 0, shipped: 0, archived: 0 }; let totalMockups = 0; let totalWhacks = 0; for (const item of allIdeas) { if (statusCounts[item.status] !== undefined) statusCounts[item.status]++; totalMockups += item.mockups.length; totalWhacks += item.whack_history.length; for (const t of item.tags) { tagCounts[t] = (tagCounts[t] || 0) + 1; } } sendJson(res, 200, { ideas, stats: { total: allIdeas.length, statusCounts, totalMockups, totalWhacks, tags: Object.entries(tagCounts) .sort((a, b) => b[1] - a[1]) .map(([name, count]) => ({ name, count })) } }); return; } // 2. POST /api/ideas if (method === "POST" && pathname === "/api/ideas") { const body = await readJsonBody(req); if (!body.title || !String(body.title).trim()) { sendJson(res, 400, { error: "Idea title is required" }); return; } const created = store.createIdea(body); sendJson(res, 201, { idea: created }); return; } // 2b. POST /api/ideas/generate-random (Personalized Random Idea Generator) if (method === "POST" && pathname === "/api/ideas/generate-random") { const body = await readJsonBody(req); const userBackground = body.userBackground || store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND; const categoryFocus = body.category || "any"; const existingTitles = store.listIdeas({}).map((i) => i.title); const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || ""; const result = await generatePersonalizedRandomIdea({ userBackground, categoryFocus, existingTitles, geminiApiKey: savedKey }); const created = store.createIdea({ ...result.ideaPayload, preserveRawTitle: true }); return sendJson(res, 201, { idea: created, card: result.card, generatorUsed: result.generatorUsed }); } // 3. GET / PATCH / DELETE /api/ideas/:id const ideaIdMatch = pathname.match(/^\/api\/ideas\/(\d+)$/); if (ideaIdMatch) { const id = Number(ideaIdMatch[1]); if (method === "GET") { const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { idea }); } if (method === "PATCH") { const body = await readJsonBody(req); const updated = store.updateIdea(id, body); if (!updated) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { idea: updated }); } if (method === "DELETE") { const deleted = store.deleteIdea(id); if (!deleted) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { deleted: true, id }); } } // 3b. POST /api/ideas/:id/suggest-names (AI Name & Tagline Brainstormer) const suggestNamesMatch = pathname.match(/^\/api\/ideas\/(\d+)\/suggest-names$/); if (method === "POST" && suggestNamesMatch) { const id = Number(suggestNamesMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const userBackground = store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND; const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || ""; const result = await generateNameSuggestions({ idea, userBackground, geminiApiKey: savedKey }); return sendJson(res, 200, result); } // 3c. GET /api/ideas/:id/spec.md (Download / Fetch Compiled Development Spec) const specMdMatch = pathname.match(/^\/api\/ideas\/(\d+)\/spec\.md$/); if (method === "GET" && specMdMatch) { const id = Number(specMdMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const targetApp = parsedUrl.searchParams.get("target") || "antigravity"; const model = parsedUrl.searchParams.get("model") || ""; const includeHomelab = parsedUrl.searchParams.get("includeHomelab") !== "false"; const userBackground = store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND; const host = req.headers.host || "sparkvault.vloser.us"; const proto = req.headers["x-forwarded-proto"] || "https"; const publicBaseUrl = `${proto}://${host}`; const md = buildDevelopmentSpecMarkdown({ idea, targetApp, model, includeHomelab, publicBaseUrl, userBackground }); res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": `attachment; filename="spark-${idea.id}-spec.md"` }); res.end(md); return; } // 3d. POST /api/ideas/:id/dev-handoff (Submit Idea to Antigravity or Codex for Development) const devHandoffMatch = pathname.match(/^\/api\/ideas\/(\d+)\/dev-handoff$/); if (method === "POST" && devHandoffMatch) { const id = Number(devHandoffMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const body = await readJsonBody(req); const targetApp = body.targetApp === "codex" ? "codex" : "antigravity"; const model = String(body.model || "").trim(); const customPrompt = String(body.customPrompt || "").trim(); const extraInstructions = String(body.extraInstructions || "").trim(); const includeHomelab = body.includeHomelab !== false; const moveStatusToBuilding = body.moveStatusToBuilding !== false; const executeServerCodex = Boolean(body.executeServerCodex); const userBackground = store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND; const host = req.headers.host || "sparkvault.vloser.us"; const proto = req.headers["x-forwarded-proto"] || "https"; const publicBaseUrl = `${proto}://${host}`; const workspacesRootDir = path.join(path.dirname(mockupsDir), "dev_workspaces"); const handoff = await executeDevelopmentHandoff({ idea, targetApp, model, customPrompt, includeHomelab, extraInstructions, executeServerCodex, publicBaseUrl, userBackground, workspacesRootDir }); // Record the handoff on the Spark and optionally move to "building" const handoffAttachment = { id: `dev-${Date.now()}`, type: "note", title: `🚀 Submitted to ${handoff.targetLabel} (${handoff.model})`, textContent: handoff.codexExecution?.output ? `Submitted to ${handoff.targetLabel} using model ${handoff.model}.\n\n--- CODEX IMPLEMENTATION PLAN ---\n${handoff.codexExecution.output}` : `Submitted full Spark specification to ${handoff.targetLabel} using model \`${handoff.model}\`.`, sourceUrl: handoff.specUrl, imageUrl: "", createdAt: handoff.submittedAt }; const patch = { attachments: [handoffAttachment, ...(idea.attachments || [])] }; if (moveStatusToBuilding && (idea.status === "spark" || idea.status === "exploring")) { patch.status = "building"; } const updatedIdea = store.updateIdea(id, patch); return sendJson(res, 200, { idea: updatedIdea, handoff, devTargets: DEV_HANDOFF_TARGETS }); } // 4. GET /api/whack-cards if (method === "GET" && pathname === "/api/whack-cards") { const allIdeas = store.listIdeas({}); const usageByCard = {}; for (const idea of allIdeas) { if (idea.inspired_by_card) { usageByCard[idea.inspired_by_card] = (usageByCard[idea.inspired_by_card] || 0) + 1; } for (const w of idea.whack_history) { usageByCard[w.cardNumber] = (usageByCard[w.cardNumber] || 0) + 1; } } const cards = WHACK_CARDS.map((c) => ({ ...c, usageCount: usageByCard[c.number] || 0 })); sendJson(res, 200, { suits: SUIT_META, cards }); return; } // 5. GET /api/whack-cards/draw if (method === "GET" && pathname === "/api/whack-cards/draw") { const mode = parsedUrl.searchParams.get("mode") || "single"; const suit = parsedUrl.searchParams.get("suit") || ""; const status = parsedUrl.searchParams.get("status") || ""; const ideaId = parsedUrl.searchParams.get("ideaId"); if (mode === "spread") { const spread = drawFourRoleSpread(); return sendJson(res, 200, { mode: "spread", cards: spread }); } if (mode === "collider") { const activeIdeas = store .listIdeas({}) .filter((i) => i.status !== "archived" && i.status !== "shipped"); let targetIdea = null; if (ideaId) { targetIdea = store.getIdea(Number(ideaId)); } if (!targetIdea && activeIdeas.length > 0) { targetIdea = activeIdeas[Math.floor(Math.random() * activeIdeas.length)]; } const drawn = drawCards({ suit: suit || undefined, status: targetIdea?.status || undefined, count: 1 })[0]; return sendJson(res, 200, { mode: "collider", card: drawn, idea: targetIdea }); } const drawn = drawCards({ suit: suit || undefined, status: status || undefined, count: 1 })[0]; sendJson(res, 200, { mode: "single", card: drawn }); return; } // 6. POST /api/ideas/:id/whack const whackMatch = pathname.match(/^\/api\/ideas\/(\d+)\/whack$/); if (method === "POST" && whackMatch) { const id = Number(whackMatch[1]); const body = await readJsonBody(req); if (!body.cardNumber) { return sendJson(res, 400, { error: "cardNumber is required" }); } const updated = store.logWhackOnIdea(id, { cardNumber: Number(body.cardNumber), reflection: body.reflection || "", addChecklistItem: Boolean(body.addChecklistItem), promoteStatus: body.promoteStatus || null }); if (!updated) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { idea: updated }); } // 7. POST /api/ideas/:id/mockups/nano-banana const nbMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/nano-banana$/); if (method === "POST" && nbMatch) { const id = Number(nbMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const body = await readJsonBody(req); const template = body.template || "tshirt-front"; const garmentColor = body.garmentColor || "#F5E8D3"; const garmentColorName = body.garmentColorName || "Vintage Cream"; const placement = body.placement || (template === "tshirt-left-chest" ? "left-chest" : "center-chest"); const whackCard = body.whackCardNumber ? getCardByNumber(body.whackCardNumber) : null; // Check if editing an existing mockup let referenceImagePath = null; let parentMockupId = body.parentMockupId || null; if (parentMockupId) { const parentMockup = idea.mockups.find((m) => m.id === parentMockupId); if (parentMockup && parentMockup.imageUrl.startsWith("/mockups/")) { const candidate = path.join(mockupsDir, path.basename(parentMockup.imageUrl)); if (fs.existsSync(candidate)) { referenceImagePath = candidate; } } } const finalPrompt = buildNanoBananaPrompt({ rawPrompt: body.prompt, ideaTitle: idea.title, elevatorPitch: idea.elevator_pitch, template, garmentColorName, garmentHex: garmentColor, whackCard, editInstruction: body.editInstruction }); const storedKey = store.getSetting("gemini_api_key", ""); const selectedModel = body.model || store.getSetting("nano_banana_model", "codex-image-gen"); try { const genResult = await generateWithNanoBanana({ apiKey: body.apiKey || storedKey, model: selectedModel, prompt: finalPrompt, referenceImagePath, referenceImageBase64: body.referenceImageBase64 || null, outputDir: mockupsDir }); const updated = store.addMockupToIdea(id, { title: body.title || (body.editInstruction ? `Edited: ${body.editInstruction.slice(0, 40)}` : `${SURFACE_PRESETS[template]?.label || "Mockup"}`), template, garmentColor, garmentColorName, placement, prompt: finalPrompt, editInstruction: body.editInstruction || "", parentMockupId, modelUsed: genResult.modelUsed, imageUrl: genResult.imageUrl }); return sendJson(res, 201, { idea: updated, mockup: updated.mockups[0], textNotes: genResult.textNotes }); } catch (err) { const status = err.code === "MISSING_API_KEY" ? 400 : 502; return sendJson(res, status, { error: err.message || "Nano Banana image generation failed", code: err.code || "GENERATION_ERROR", promptBuilt: finalPrompt }); } } // 8. POST /api/ideas/:id/mockups/canvas const canvasMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/canvas$/); if (method === "POST" && canvasMatch) { const id = Number(canvasMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const body = await readJsonBody(req); if (!body.imageDataUrl || !String(body.imageDataUrl).startsWith("data:image/")) { return sendJson(res, 400, { error: "Valid imageDataUrl (data:image/png;base64,...) is required" }); } const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); const filename = `mockup-${id}-${Date.now()}.png`; const filePath = path.join(mockupsDir, filename); fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); const updated = store.addMockupToIdea(id, { title: body.title || `${idea.title} (${body.placement === "left-chest" ? "Left-Chest" : "Mockup"})`, template: body.template || "tshirt-front", garmentColor: body.garmentColor || "#F5E8D3", garmentColorName: body.garmentColorName || "Vintage Cream", placement: body.placement || "center-chest", prompt: body.prompt || "", topText: body.topText || "", bottomText: body.bottomText || "", modelUsed: body.modelUsed || "Interactive Mockup Compositor", imageUrl: `/mockups/${filename}` }); return sendJson(res, 201, { idea: updated, mockup: updated.mockups[0] }); } // 9. PATCH /api/ideas/:id/mockups/:mockupId/cover & DELETE /api/ideas/:id/mockups/:mockupId const mockupItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/([^/]+)(?:\/(cover))?$/); if (mockupItemMatch) { const id = Number(mockupItemMatch[1]); const mockupId = mockupItemMatch[2]; const isCoverAction = mockupItemMatch[3] === "cover"; const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); if (method === "PATCH" && isCoverAction) { const target = idea.mockups.find((m) => m.id === mockupId); if (!target) return sendJson(res, 404, { error: "Mockup not found" }); const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl }); return sendJson(res, 200, { idea: updated }); } if (method === "DELETE" && !isCoverAction) { const nextMockups = idea.mockups.filter((m) => m.id !== mockupId); const nextCover = idea.cover_mockup_url && nextMockups.some((m) => m.imageUrl === idea.cover_mockup_url) ? idea.cover_mockup_url : nextMockups[0]?.imageUrl || ""; const updated = store.updateIdea(id, { mockups: nextMockups, cover_mockup_url: nextCover }); return sendJson(res, 200, { idea: updated }); } } // 9b. POST /api/ideas/:id/attachments & DELETE/PATCH /api/ideas/:id/attachments/:attId const attPostMatch = pathname.match(/^\/api\/ideas\/(\d+)\/attachments$/); if (method === "POST" && attPostMatch) { const id = Number(attPostMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const body = await readJsonBody(req); let savedImageUrl = ""; // Option 1: Base64 / Data URL image (upload, drag-and-drop, or clipboard paste) if (body.imageDataUrl && String(body.imageDataUrl).startsWith("data:image/")) { const mimeMatch = String(body.imageDataUrl).match(/^data:image\/([a-zA-Z0-9+.-]+);base64,/); const rawExt = mimeMatch ? mimeMatch[1].toLowerCase() : "png"; const ext = rawExt === "jpeg" ? "jpg" : rawExt === "svg+xml" ? "svg" : rawExt; const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); const filename = `att-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`; const filePath = path.join(mockupsDir, filename); fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); savedImageUrl = `/mockups/${filename}`; } // Option 2: External image URL (fetch & cache locally so it never breaks, or store URL fallback) else if (body.externalImageUrl && /^https?:\/\//i.test(String(body.externalImageUrl).trim())) { const remoteUrl = String(body.externalImageUrl).trim(); try { const resp = await fetch(remoteUrl, { headers: { "User-Agent": "SparkVault/1.0" }, signal: AbortSignal.timeout(10000) }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const ct = (resp.headers.get("content-type") || "").toLowerCase(); const ext = ct.includes("jpeg") || ct.includes("jpg") ? "jpg" : ct.includes("webp") ? "webp" : ct.includes("svg") ? "svg" : ct.includes("gif") ? "gif" : "png"; const arrBuf = await resp.arrayBuffer(); const filename = `att-url-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`; fs.writeFileSync(path.join(mockupsDir, filename), Buffer.from(arrBuf)); savedImageUrl = `/mockups/${filename}`; } catch { // Fallback to direct external URL if remote host blocks server-side fetch savedImageUrl = remoteUrl; } } const type = savedImageUrl ? "image" : "note"; if (type === "note" && !String(body.textContent || "").trim() && !String(body.sourceUrl || "").trim()) { return sendJson(res, 400, { error: "Provide an image, image URL, or note text to attach" }); } let updated = store.addAttachmentToIdea(id, { type, title: String(body.title || "").trim() || (type === "image" ? "Reference Image" : "External Note Clip"), textContent: String(body.textContent || "").trim(), sourceUrl: String(body.sourceUrl || body.externalImageUrl || "").trim(), imageUrl: savedImageUrl, setAsCover: Boolean(body.setAsCover), appendToNotes: Boolean(body.appendToNotes) }); // Also add sourceUrl to idea links if provided if (body.sourceUrl && /^https?:\/\//i.test(String(body.sourceUrl).trim())) { const cleanUrl = String(body.sourceUrl).trim(); if (!updated.links.some((l) => l.url === cleanUrl)) { updated = store.updateIdea(id, { links: [ ...updated.links, { id: `lnk-${Date.now()}`, title: String(body.title || cleanUrl).trim(), url: cleanUrl } ] }); } } return sendJson(res, 201, { idea: updated, attachment: updated.attachments[0] }); } const attItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/attachments\/([^/]+)(?:\/(cover|promote-mockup))?$/); if (attItemMatch) { const id = Number(attItemMatch[1]); const attId = attItemMatch[2]; const action = attItemMatch[3]; const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const target = (idea.attachments || []).find((a) => a.id === attId); if (!target) return sendJson(res, 404, { error: "Attachment not found" }); if (method === "PATCH" && action === "cover") { if (!target.imageUrl) return sendJson(res, 400, { error: "Attachment has no image" }); const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl }); return sendJson(res, 200, { idea: updated }); } if (method === "POST" && action === "promote-mockup") { if (!target.imageUrl) return sendJson(res, 400, { error: "Attachment has no image" }); const updated = store.addMockupToIdea(id, { title: target.title || "Imported Reference Image", template: "isolated-graphic", prompt: target.textContent || target.title || "", modelUsed: "External Attachment", imageUrl: target.imageUrl }); return sendJson(res, 201, { idea: updated }); } if (method === "DELETE" && !action) { const nextAtt = (idea.attachments || []).filter((a) => a.id !== attId); const updated = store.updateIdea(id, { attachments: nextAtt }); return sendJson(res, 200, { idea: updated }); } } // 9c. POST /api/ingest (External Clipper / iOS Shortcut / Bookmarklet / Share Target) if (method === "POST" && pathname === "/api/ingest") { const body = await readJsonBody(req); let targetIdeaId = body.ideaId ? Number(body.ideaId) : null; let idea = targetIdeaId ? store.getIdea(targetIdeaId) : null; if (!idea) { idea = store.createIdea({ title: body.title || "External Spark Clip", elevator_pitch: body.elevator_pitch || "", category: body.category || "spark", notes_markdown: body.notes_markdown || body.textContent || "", tags: Array.isArray(body.tags) ? body.tags : ["clipped"] }); } let savedImageUrl = ""; if (body.imageDataUrl && String(body.imageDataUrl).startsWith("data:image/")) { const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); const filename = `clip-${idea.id}-${Date.now()}.png`; fs.writeFileSync(path.join(mockupsDir, filename), Buffer.from(base64Data, "base64")); savedImageUrl = `/mockups/${filename}`; } else if (body.externalImageUrl && /^https?:\/\//i.test(String(body.externalImageUrl).trim())) { savedImageUrl = String(body.externalImageUrl).trim(); } if (savedImageUrl || body.textContent || body.sourceUrl) { idea = store.addAttachmentToIdea(idea.id, { type: savedImageUrl ? "image" : "note", title: body.attachmentTitle || body.title || "External Clip", textContent: body.textContent || "", sourceUrl: body.sourceUrl || "", imageUrl: savedImageUrl, setAsCover: Boolean(savedImageUrl) }); } return sendJson(res, 201, { idea }); } // 10. GET /api/review/next & POST /api/review/arena if (method === "GET" && pathname === "/api/review/next") { const allIdeas = store.listIdeas({}); const activeIdeas = allIdeas.filter((i) => i.status !== "archived" && i.status !== "shipped"); const triageQueue = [...activeIdeas].sort((a, b) => { if (!a.last_reviewed_at && b.last_reviewed_at) return -1; if (a.last_reviewed_at && !b.last_reviewed_at) return 1; if (a.last_reviewed_at && b.last_reviewed_at) { return a.last_reviewed_at.localeCompare(b.last_reviewed_at); } return a.created_at.localeCompare(b.created_at); }); let arenaPair = []; if (activeIdeas.length >= 2) { const shuffled = [...activeIdeas].sort(() => Math.random() - 0.5); arenaPair = [shuffled[0], shuffled[1]]; } return sendJson(res, 200, { triageQueue, arenaPair }); } if (method === "POST" && pathname === "/api/review/arena") { const body = await readJsonBody(req); if (!body.winnerId || !body.loserId) { return sendJson(res, 400, { error: "winnerId and loserId are required" }); } const result = store.recordArenaMatch({ winnerId: Number(body.winnerId), loserId: Number(body.loserId) }); if (!result) return sendJson(res, 404, { error: "Ideas not found" }); return sendJson(res, 200, result); } // 11. GET / PUT /api/settings if (pathname === "/api/settings") { if (method === "GET") { const savedKey = store.getSetting("gemini_api_key", ""); const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; const activeKey = savedKey || envKey; const codexReady = isCodexConfigured(); const model = store.getSetting("nano_banana_model", "codex-image-gen"); return sendJson(res, 200, { hasApiKey: Boolean(activeKey) || codexReady, codexReady, apiKeySource: codexReady ? "OpenAI Codex (OAuth)" : savedKey ? "settings" : envKey ? "environment" : "none", maskedKey: activeKey ? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}` : codexReady ? "Authenticated via ~/.codex/auth.json" : "", nanoBananaModel: model, userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND, availableModels: NANO_BANANA_MODELS, surfacePresets: Object.entries(SURFACE_PRESETS).map(([id, val]) => ({ id, label: val.label })) }); } if (method === "PUT") { const body = await readJsonBody(req); if (body.geminiApiKey !== undefined) { store.setSetting("gemini_api_key", String(body.geminiApiKey).trim()); } if (body.nanoBananaModel !== undefined) { store.setSetting("nano_banana_model", String(body.nanoBananaModel).trim()); } if (body.userBackground !== undefined) { store.setSetting("user_background", String(body.userBackground).trim()); } const savedKey = store.getSetting("gemini_api_key", ""); const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; const activeKey = savedKey || envKey; const codexReady = isCodexConfigured(); return sendJson(res, 200, { saved: true, hasApiKey: Boolean(activeKey) || codexReady, codexReady, nanoBananaModel: store.getSetting("nano_banana_model", "codex-image-gen"), userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND }); } } // 12. GET /api/export if (method === "GET" && pathname === "/api/export") { const format = parsedUrl.searchParams.get("format") || "json"; const ideas = store.listIdeas({}); if (format === "markdown") { const mdLines = [ `# SparkVault Idea Export (${new Date().toISOString().slice(0, 10)})`, "" ]; for (const idea of ideas) { mdLines.push(`## [${idea.status.toUpperCase()}] ${idea.title} (Score: ${idea.composite_score})`); if (idea.elevator_pitch) mdLines.push(`> ${idea.elevator_pitch}`); mdLines.push(`- **Category**: ${idea.category} | **Tags**: ${idea.tags.map((t) => `#${t}`).join(" ") || "none"}`); mdLines.push(`- **Ratings**: Excitement ${idea.rating_excitement}/5, Impact ${idea.rating_impact}/5, Ease ${idea.rating_ease}/5 | **Arena Wins**: ${idea.arena_wins}`); if (idea.notes_markdown) { mdLines.push("", idea.notes_markdown); } if (idea.checklist.length > 0) { mdLines.push("", "### Checklist"); for (const item of idea.checklist) { mdLines.push(`- [${item.done ? "x" : " "}] ${item.text}`); } } if (idea.whack_history.length > 0) { mdLines.push("", "### Creative Whack Pack Reflections"); for (const w of idea.whack_history) { mdLines.push(`- **Card #${w.cardNumber} (${w.cardTitle})**: ${w.reflection}`); } } mdLines.push("", "---", ""); } res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": 'attachment; filename="sparkvault-ideas.md"' }); res.end(mdLines.join("\n")); return; } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Content-Disposition": 'attachment; filename="sparkvault-ideas.json"' }); res.end(JSON.stringify({ exportedAt: new Date().toISOString(), ideas }, null, 2)); return; } // --- STATIC ASSETS --- if (pathname.startsWith("/mockups/")) { const rel = pathname.slice("/mockups/".length); const safePath = path.normalize(path.join(mockupsDir, rel)); if (!safePath.startsWith(mockupsDir)) { return sendJson(res, 403, { error: "Forbidden" }); } return serveStaticFile(res, safePath); } const relPath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); const staticPath = path.normalize(path.join(PUBLIC_DIR, relPath)); if (!staticPath.startsWith(PUBLIC_DIR)) { return sendJson(res, 403, { error: "Forbidden" }); } if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) { return serveStaticFile(res, staticPath); } // SPA Fallback return serveStaticFile(res, path.join(PUBLIC_DIR, "index.html")); } catch (err) { console.error("Server error:", err); sendJson(res, 500, { error: err.message || "Internal server error" }); } }); return { server, store }; } // Start server if invoked directly if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { const port = Number(process.env.PORT || 3456); const { server } = createAppServer(); server.listen(port, () => { console.log(`✨ SparkVault Idea Tracker running at http://localhost:${port}`); }); }