Make every plugin field live, and support draft plugins that bypass git entirely

This commit is contained in:
Ally Founder 2026-08-02 15:04:35 -04:00
parent 420bc89ad3
commit 90eb694948
4 changed files with 285 additions and 91 deletions

View file

@ -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
);

View file

@ -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<Record<string, OverlayEntry>> {
const { results } = await db.prepare("SELECT * FROM overlay").all<Record<string, unknown>>();
const out: Record<string, OverlayEntry> = {};
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<Record<string, OverlayEntry & { draft: boolean }>> {
const { results } = await db.prepare("SELECT * FROM overlay").all<Row>();
const out: Record<string, OverlayEntry & { draft: boolean }> = {};
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<Record<string, OverlayEntry & { draft: boolean }>> {
const cached = await env.OVERLAY_CACHE.get(CACHE_KEY, "json");
if (cached) return cached as Record<string, OverlayEntry & { draft: boolean }>;
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<string> {
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<Response> {
try {
@ -57,67 +130,180 @@ export default {
};
async function handle(request: Request, env: Env): Promise<Response> {
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<any[]>() : [];
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<OverlayEntry>().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<OverlayEntry>().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`,
};
}

View file

@ -5,6 +5,7 @@ compatibility_date = "2024-11-01"
[assets]
directory = "../dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
[[d1_databases]]
binding = "DB"

View file

@ -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");