From 90eb69494807c6fb7c96e84a80b5a384a9f8529e Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sun, 2 Aug 2026 15:04:35 -0400 Subject: [PATCH] Make every plugin field live, and support draft plugins that bypass git entirely --- api/schema.sql | 25 ++- api/src/index.ts | 326 ++++++++++++++++++++++++++++++-------- api/wrangler.toml | 1 + scripts/generate-site.mjs | 24 +-- 4 files changed, 285 insertions(+), 91 deletions(-) diff --git a/api/schema.sql b/api/schema.sql index 66f6d48..f559aa4 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -1,11 +1,28 @@ --- The overlay is deliberately thin: site/meta.json (git-tracked) stays the source of truth for --- real plugin content (descriptions, features, howItWorks). This table only holds the bits meant --- to be flipped from a phone without a git push: status label, a "broken" flag + reason, and tags. -CREATE TABLE IF NOT EXISTS overlay ( +-- Every displayed field can now be overridden here, not just status/broken/tags - editing anything +-- from the admin plugin should show up on the live site immediately, no git push needed. A row can +-- also stand entirely on its own (is_draft = 1): a plugin that doesn't exist in the git repo at all, +-- created straight from the admin plugin for quick testing, with its own manifest/JS served directly +-- from here instead of from the built dist/ output. +DROP TABLE IF EXISTS overlay; +CREATE TABLE overlay ( id TEXT PRIMARY KEY, + name TEXT, + description TEXT, + category TEXT, status TEXT, + accent TEXT, + tagline TEXT, + note TEXT, + how_it_works TEXT, + features TEXT, -- JSON array of strings + commands TEXT, -- JSON array of {cmd, desc} + limitations TEXT, + authors TEXT, -- JSON array of strings broken_reason TEXT, broken_since TEXT, tags TEXT NOT NULL DEFAULT '[]', + is_draft INTEGER NOT NULL DEFAULT 0, + manifest TEXT, -- full manifest.json content - only used when is_draft = 1 + main_js TEXT, -- index.js source - only used when is_draft = 1 updated_at TEXT NOT NULL ); diff --git a/api/src/index.ts b/api/src/index.ts index 1d76d2e..6e237f5 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,15 +5,58 @@ export interface Env { ADMIN_TOKEN: string; } -interface OverlayEntry { - status?: string | null; - broken?: { reason: string; since?: string } | null; - tags?: string[]; +interface Command { + cmd: string; + desc: string; } -// Short TTL, not a long-lived cache - the whole point of this overlay is that a status/broken/tag -// flip from a phone should be visible almost immediately, not after a long edge-cache window. -const CACHE_KEY = "overlay:v1"; +// Every field is optional - a PUT only needs to send what it wants to change, and whatever isn't +// sent falls back to the git-tracked base (for a plugin that exists in git) or a sane default (for +// a brand-new draft-only plugin, which has no base to fall back to at all). +interface OverlayEntry { + name?: string; + description?: string; + category?: string; + status?: string | null; + accent?: string; + tagline?: string; + note?: string; + howItWorks?: string; + features?: string[]; + commands?: Command[]; + limitations?: string; + authors?: string[]; + broken?: { reason: string; since?: string } | null; + tags?: string[]; + draft?: boolean; + mainJs?: string; // raw plugin source - only meaningful for a draft, ignored otherwise +} + +interface Row { + id: string; + name: string | null; + description: string | null; + category: string | null; + status: string | null; + accent: string | null; + tagline: string | null; + note: string | null; + how_it_works: string | null; + features: string | null; + commands: string | null; + limitations: string | null; + authors: string | null; + broken_reason: string | null; + broken_since: string | null; + tags: string; + is_draft: number; + manifest: string | null; + main_js: string | null; +} + +// Short TTL, not a long-lived cache - the whole point of this overlay is that a change from a phone +// should be visible almost immediately, not after a long edge-cache window. +const CACHE_KEY = "overlay:v2"; const CACHE_TTL_SECONDS = 60; // Workers KV's enforced minimum const CORS_HEADERS = { @@ -29,22 +72,52 @@ function json(data: unknown, status = 200): Response { }); } -async function readAll(db: D1Database): Promise> { - const { results } = await db.prepare("SELECT * FROM overlay").all>(); - const out: Record = {}; - for (const row of results) { - const id = row.id as string; - out[id] = { - status: (row.status as string) ?? undefined, - broken: row.broken_reason - ? { reason: row.broken_reason as string, since: row.broken_since as string } - : null, - tags: row.tags ? JSON.parse(row.tags as string) : [], - }; - } +function rowToOverlay(row: Row): OverlayEntry & { draft: boolean } { + return { + name: row.name ?? undefined, + description: row.description ?? undefined, + category: row.category ?? undefined, + status: row.status ?? undefined, + accent: row.accent ?? undefined, + tagline: row.tagline ?? undefined, + note: row.note ?? undefined, + howItWorks: row.how_it_works ?? undefined, + features: row.features ? JSON.parse(row.features) : undefined, + commands: row.commands ? JSON.parse(row.commands) : undefined, + limitations: row.limitations ?? undefined, + authors: row.authors ? JSON.parse(row.authors) : undefined, + broken: row.broken_reason ? { reason: row.broken_reason, since: row.broken_since ?? undefined } : null, + tags: row.tags ? JSON.parse(row.tags) : [], + draft: !!row.is_draft, + }; +} + +async function readAll(db: D1Database): Promise> { + const { results } = await db.prepare("SELECT * FROM overlay").all(); + const out: Record = {}; + for (const row of results) out[row.id] = rowToOverlay(row); return out; } +// Shared by every read path (GET /api/overlay, GET /plugins-data.json, and install-file serving for +// draft plugins) so a change from the admin plugin shows up everywhere on the next request, not just +// on the raw overlay endpoint. +async function getOverlay(env: Env): Promise> { + const cached = await env.OVERLAY_CACHE.get(CACHE_KEY, "json"); + if (cached) return cached as Record; + + const data = await readAll(env.DB); + await env.OVERLAY_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL_SECONDS }); + return data; +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +const DEFAULT_MAIN_JS = `export default {\n onLoad: () => {},\n onUnload: () => {}\n};\n`; + export default { async fetch(request: Request, env: Env): Promise { try { @@ -57,67 +130,180 @@ export default { }; async function handle(request: Request, env: Env): Promise { - const url = new URL(request.url); + const url = new URL(request.url); - if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) { - return new Response(null, { headers: CORS_HEADERS }); + if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) { + return new Response(null, { headers: CORS_HEADERS }); + } + + // The site's actual data file - computed fresh on every request by merging the git-tracked base + // (plugins-base.json, written by generate-site.mjs at build time) with the live overlay, so any + // change from the admin plugin is visible immediately, with no rebuild. Draft-only plugins (no + // git base at all) are appended as their own entries. + if (url.pathname === "/plugins-data.json") { + const baseRes = await env.ASSETS.fetch(new URL("/plugins-base.json", request.url)); + const base = baseRes.ok ? await baseRes.json() : []; + const overlay = await getOverlay(env); + const baseIds = new Set(base.map((p) => p.id)); + + const merged = base.map((p) => applyOverride(p, overlay[p.id])); + const draftOnly = Object.entries(overlay) + .filter(([id, o]) => o.draft && !baseIds.has(id)) + .map(([id, o]) => synthesizeDraft(id, o)); + + return json([...merged, ...draftOnly]); + } + + // Dynamic install files for draft-only plugins (created via PUT /api/overlay/:id with draft:true + // and mainJs). Checked against D1 *first*, not by probing the static asset's response status - + // with SPA fallback enabled (see wrangler.toml's not_found_handling), a missing asset no longer + // 404s, it serves index.html with a 200, so that status can't be used to detect "not found" here. + // A real git-tracked plugin has no row here, so this always falls through to ASSETS for it. + const installMatch = url.pathname.match(/^\/([\w-]+)\/install\/(manifest\.json|index\.js)$/); + if (installMatch) { + const [, id, file] = installMatch; + const row = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1 AND is_draft = 1") + .bind(id).first<{ manifest: string; main_js: string }>(); + + if (row) { + if (file === "manifest.json") return json(JSON.parse(row.manifest)); + return new Response(row.main_js, { headers: { "content-type": "application/javascript", ...CORS_HEADERS } }); } - if (!url.pathname.startsWith("/api/")) { - return env.ASSETS.fetch(request); + return env.ASSETS.fetch(request); + } + + if (!url.pathname.startsWith("/api/")) { + return env.ASSETS.fetch(request); + } + + // GET /api/overlay - public read, used by the admin plugin/site to show current values before + // editing. + if (url.pathname === "/api/overlay" && request.method === "GET") { + return json(await getOverlay(env)); + } + + const match = url.pathname.match(/^\/api\/overlay\/([\w-]+)$/); + if (match) { + const id = match[1]; + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${env.ADMIN_TOKEN}`) { + return json({ error: "unauthorized" }, 401); } - // GET /api/overlay - public read, used at site build time (generate-site.mjs) and by the - // admin plugin/site to show current values before editing. - if (url.pathname === "/api/overlay" && request.method === "GET") { - const cached = await env.OVERLAY_CACHE.get(CACHE_KEY, "json"); - if (cached) return json(cached); + if (request.method === "PUT") { + const body = await request.json().catch(() => null); + if (!body) return json({ error: "invalid body" }, 400); - const data = await readAll(env.DB); - await env.OVERLAY_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL_SECONDS }); - return json(data); - } + const existing = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1").bind(id).first<{ manifest: string; main_js: string }>(); - const match = url.pathname.match(/^\/api\/overlay\/([\w-]+)$/); - if (match) { - const id = match[1]; - const auth = request.headers.get("authorization"); - if (auth !== `Bearer ${env.ADMIN_TOKEN}`) { - return json({ error: "unauthorized" }, 401); + let manifest = existing?.manifest ?? null; + let mainJs = existing?.main_js ?? null; + if (body.draft) { + mainJs = body.mainJs ?? mainJs ?? DEFAULT_MAIN_JS; + const manifestObj = { + name: body.name ?? id, + description: body.description ?? "", + authors: (body.authors ?? []).map((n) => ({ name: n })), + main: "index.js", + hash: await sha256Hex(mainJs), + }; + manifest = JSON.stringify(manifestObj); } - if (request.method === "PUT") { - const body = await request.json().catch(() => null); - if (!body) return json({ error: "invalid body" }, 400); + await env.DB.prepare( + `INSERT INTO overlay ( + id, name, description, category, status, accent, tagline, note, how_it_works, + features, commands, limitations, authors, broken_reason, broken_since, tags, + is_draft, manifest, main_js, updated_at + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, description = excluded.description, category = excluded.category, + status = excluded.status, accent = excluded.accent, tagline = excluded.tagline, + note = excluded.note, how_it_works = excluded.how_it_works, features = excluded.features, + commands = excluded.commands, limitations = excluded.limitations, authors = excluded.authors, + broken_reason = excluded.broken_reason, broken_since = excluded.broken_since, tags = excluded.tags, + is_draft = excluded.is_draft, manifest = excluded.manifest, main_js = excluded.main_js, + updated_at = excluded.updated_at`, + ).bind( + id, + body.name ?? null, + body.description ?? null, + body.category ?? null, + body.status ?? null, + body.accent ?? null, + body.tagline ?? null, + body.note ?? null, + body.howItWorks ?? null, + body.features ? JSON.stringify(body.features) : null, + body.commands ? JSON.stringify(body.commands) : null, + body.limitations ?? null, + body.authors ? JSON.stringify(body.authors) : null, + body.broken?.reason ?? null, + body.broken ? (body.broken.since ?? new Date().toISOString()) : null, + JSON.stringify(body.tags ?? []), + body.draft ? 1 : 0, + manifest, + mainJs, + new Date().toISOString(), + ).run(); - await env.DB.prepare( - `INSERT INTO overlay (id, status, broken_reason, broken_since, tags, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(id) DO UPDATE SET - status = excluded.status, - broken_reason = excluded.broken_reason, - broken_since = excluded.broken_since, - tags = excluded.tags, - updated_at = excluded.updated_at`, - ).bind( - id, - body.status ?? null, - body.broken?.reason ?? null, - body.broken ? (body.broken.since ?? new Date().toISOString()) : null, - JSON.stringify(body.tags ?? []), - new Date().toISOString(), - ).run(); - - await env.OVERLAY_CACHE.delete(CACHE_KEY); - return json({ ok: true }); - } - - if (request.method === "DELETE") { - await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run(); - await env.OVERLAY_CACHE.delete(CACHE_KEY); - return json({ ok: true }); - } + await env.OVERLAY_CACHE.delete(CACHE_KEY); + return json({ ok: true }); } + if (request.method === "DELETE") { + await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run(); + await env.OVERLAY_CACHE.delete(CACHE_KEY); + return json({ ok: true }); + } + } + return json({ error: "not found" }, 404); } + +function applyOverride(base: any, over: (OverlayEntry & { draft: boolean }) | undefined) { + if (!over) return { ...base, broken: null, tags: [] }; + return { + ...base, + name: over.name ?? base.name, + description: over.description ?? base.description, + category: over.category ?? base.category, + status: over.status ?? base.status, + accent: over.accent ?? base.accent, + tagline: over.tagline ?? base.tagline, + note: over.note ?? base.note, + howItWorks: over.howItWorks ?? base.howItWorks, + features: over.features ?? base.features, + commands: over.commands ?? base.commands, + limitations: over.limitations ?? base.limitations, + authors: over.authors ?? base.authors, + broken: over.broken ?? null, + tags: over.tags ?? [], + }; +} + +function synthesizeDraft(id: string, over: OverlayEntry & { draft: boolean }) { + const base = "https://rp.jarviscli.dev"; + return { + id, + name: over.name ?? id, + description: over.description ?? "", + authors: over.authors ?? [], + category: over.category ?? "Other", + status: over.status ?? "new", + broken: over.broken ?? null, + tags: over.tags ?? [], + accent: over.accent ?? "#5865f2", + tagline: over.tagline ?? over.description ?? "", + note: over.note ?? "Draft plugin - created from the admin plugin, not tracked in git.", + howItWorks: over.howItWorks ?? "", + features: over.features ?? [], + commands: over.commands ?? [], + limitations: over.limitations ?? "", + pageUrl: `${base}/${id}/`, + installUrl: `${base}/${id}/install/`, + sourceUrl: "https://github.com/everestmcarthur/revenge-plugins", + issueUrl: `https://github.com/everestmcarthur/revenge-plugins/issues/new`, + }; +} diff --git a/api/wrangler.toml b/api/wrangler.toml index 88e3747..d541aff 100644 --- a/api/wrangler.toml +++ b/api/wrangler.toml @@ -5,6 +5,7 @@ compatibility_date = "2024-11-01" [assets] directory = "../dist" binding = "ASSETS" +not_found_handling = "single-page-application" [[d1_databases]] binding = "DB" diff --git a/scripts/generate-site.mjs b/scripts/generate-site.mjs index d0ba0b2..b4bf974 100644 --- a/scripts/generate-site.mjs +++ b/scripts/generate-site.mjs @@ -6,18 +6,11 @@ const REPO_BASE = "https://github.com/everestmcarthur/revenge-plugins"; const meta = JSON.parse(await readFile("./site/meta.json", "utf8")); const plugins = []; -// The overlay holds the fast-changing bits (status, "broken" flag/reason, tags) meant to be flipped -// from the admin plugin without a git push - meta.json above stays the source of truth for real -// content. A fetch failure here (API down, first-ever build before it exists) must never break the -// public build, so this always falls back to an empty overlay. -let overlay = {}; -try { - const res = await fetch(`${PAGES_BASE}/api/overlay`); - if (res.ok) overlay = await res.json(); -} catch { - console.warn("Couldn't reach the overlay API - building with meta.json alone."); -} - +// This file intentionally does NOT merge in the live overlay (status/broken/tags) anymore - that +// used to happen here at build time, which meant flipping something broken from the admin plugin +// only showed up on the site after the next git push. The Worker (api/src/index.ts) now merges +// plugins-base.json (written below, git-tracked content only) with the live overlay on every +// request to /plugins-data.json, so admin changes are visible immediately with no rebuild needed. for (const id of await readdir("./dist")) { const dir = `./dist/${id}`; if (!(await stat(dir)).isDirectory()) continue; @@ -30,7 +23,6 @@ for (const id of await readdir("./dist")) { } const info = meta[id] ?? {}; - const over = overlay[id] ?? {}; plugins.push({ id, @@ -38,9 +30,7 @@ for (const id of await readdir("./dist")) { description: manifest.description, authors: (manifest.authors ?? []).map((a) => a.name), category: info.category ?? "Other", - status: over.status ?? info.status ?? "new", - broken: over.broken ?? null, - tags: over.tags ?? [], + status: info.status ?? "new", accent: info.accent ?? "#5865f2", tagline: info.tagline ?? manifest.description, note: info.note ?? "", @@ -57,7 +47,7 @@ for (const id of await readdir("./dist")) { plugins.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); -await writeFile("./dist/plugins-data.json", JSON.stringify(plugins, null, 2)); +await writeFile("./dist/plugins-base.json", JSON.stringify(plugins, null, 2)); await copyFile("./site/index.html", "./dist/index.html"); await copyFile("./site/index.html", "./dist/404.html"); await copyFile("./site/CNAME", "./dist/CNAME");