WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at root/atb-56-theme-caching-layer 309 lines 11 kB view raw
1import { defineCommand } from "citty"; 2import consola from "consola"; 3import { readFileSync } from "fs"; 4import { fileURLToPath } from "url"; 5import { dirname, join } from "path"; 6import { ForumAgent } from "@atbb/atproto"; 7import { loadCliConfig } from "../lib/config.js"; 8import { logger } from "../lib/logger.js"; 9 10const __dirname = dirname(fileURLToPath(import.meta.url)); 11const PRESET_DIR = join(__dirname, "../../../../apps/web/src/styles/presets"); 12 13const PRESETS = [ 14 { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 15 { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 16 { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 17 { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 18 { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 19] as const; 20 21/** Stable JSON string for comparison — sorted token keys, ignores ordering differences. */ 22function stableTokensJson(tokens: Record<string, string>): string { 23 return JSON.stringify(Object.fromEntries(Object.entries(tokens).sort())); 24} 25 26/** True if the existing PDS record has the same content as the local preset. */ 27function isRecordCurrent( 28 existing: Record<string, unknown>, 29 preset: { name: string; colorScheme: string }, 30 tokens: Record<string, string> 31): boolean { 32 return ( 33 existing.name === preset.name && 34 existing.colorScheme === preset.colorScheme && 35 stableTokensJson(existing.tokens as Record<string, string>) === stableTokensJson(tokens) 36 ); 37} 38 39/** 40 * Authenticate using ForumAgent and return the raw agent + DID. 41 * Theme commands only need PDS_URL, FORUM_HANDLE, FORUM_PASSWORD — 42 * DATABASE_URL and FORUM_DID are not required. 43 */ 44async function authenticate(config: ReturnType<typeof loadCliConfig>) { 45 const themeEnvMissing = config.missing.filter( 46 (v) => v !== "DATABASE_URL" && v !== "FORUM_DID" 47 ); 48 if (themeEnvMissing.length > 0) { 49 consola.error("Missing required environment variables:"); 50 for (const name of themeEnvMissing) consola.error(` - ${name}`); 51 process.exit(1); 52 } 53 54 consola.start("Authenticating..."); 55 const forumAgent = new ForumAgent( 56 config.pdsUrl, config.forumHandle, config.forumPassword, logger 57 ); 58 59 try { 60 await forumAgent.initialize(); 61 } catch (error) { 62 consola.error( 63 `Failed to reach PDS (${config.pdsUrl}):`, 64 error instanceof Error ? error.message : String(error) 65 ); 66 try { await forumAgent.shutdown(); } catch {} 67 process.exit(1); 68 } 69 70 if (!forumAgent.isAuthenticated()) { 71 const status = forumAgent.getStatus(); 72 consola.error(`Authentication failed: ${status.error}`); 73 await forumAgent.shutdown(); 74 process.exit(1); 75 } 76 77 const agent = forumAgent.getAgent()!; 78 const did = agent.session?.did; 79 if (!did) { 80 consola.error("Login succeeded but session has no DID"); 81 await forumAgent.shutdown(); 82 process.exit(1); 83 } 84 85 consola.success(`Authenticated as ${config.forumHandle} (${did})`); 86 return { agent, did, forumAgent }; 87} 88 89// ── bootstrap-local ────────────────────────────────────────────────────────── 90 91const bootstrapLocalCommand = defineCommand({ 92 meta: { 93 name: "bootstrap-local", 94 description: 95 "Mirror built-in preset themes to your own PDS — zero external dependencies", 96 }, 97 args: { 98 "dry-run": { 99 type: "boolean", 100 description: "Show what would be written without making any changes", 101 default: false, 102 }, 103 }, 104 async run({ args }) { 105 const isDryRun = args["dry-run"]; 106 consola.box("atBB — Bootstrap Local Presets" + (isDryRun ? " [dry-run]" : "")); 107 108 const config = loadCliConfig(); 109 const { agent, did, forumAgent } = await authenticate(config); 110 111 consola.info(`Writing ${PRESETS.length} preset records to ${config.pdsUrl}`); 112 if (isDryRun) consola.warn("Dry-run: no changes will be made."); 113 consola.log(""); 114 115 const now = new Date().toISOString(); 116 const localUris: Array<{ rkey: string; uri: string }> = []; 117 118 for (const preset of PRESETS) { 119 const tokens = JSON.parse( 120 readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 121 ) as Record<string, string>; 122 123 const uri = `at://${did}/space.atbb.forum.theme/${preset.rkey}`; 124 localUris.push({ rkey: preset.rkey, uri }); 125 126 if (isDryRun) { 127 consola.info(` ~ ${preset.name} — would write ${uri}`); 128 continue; 129 } 130 131 await agent.com.atproto.repo.putRecord({ 132 repo: did, 133 collection: "space.atbb.forum.theme", 134 rkey: preset.rkey, 135 record: { 136 $type: "space.atbb.forum.theme", 137 name: preset.name, 138 colorScheme: preset.colorScheme, 139 tokens, 140 createdAt: now, 141 updatedAt: now, 142 }, 143 }); 144 consola.success(` ${preset.name}`); 145 } 146 147 const lightUri = localUris.find((t) => t.rkey === "neobrutal-light")!.uri; 148 const darkUri = localUris.find((t) => t.rkey === "neobrutal-dark")!.uri; 149 const available = localUris.map((t) => ({ uri: t.uri })); 150 151 consola.log(""); 152 153 // Show existing themePolicy before overwriting so operators can see what will change 154 try { 155 const existing = await agent.com.atproto.repo.getRecord({ 156 repo: did, 157 collection: "space.atbb.forum.themePolicy", 158 rkey: "self", 159 }); 160 const rec = existing.data.value as Record<string, unknown>; 161 const existingLight = (rec.defaultLightTheme as Record<string, string> | undefined)?.uri; 162 const existingDark = (rec.defaultDarkTheme as Record<string, string> | undefined)?.uri; 163 consola.info("Existing themePolicy:"); 164 consola.info(` defaultLightTheme: ${existingLight ?? "(none)"}`); 165 consola.info(` defaultDarkTheme: ${existingDark ?? "(none)"}`); 166 } catch { 167 consola.info("No existing themePolicy — will create."); 168 } 169 170 if (isDryRun) { 171 consola.info(` ~ themePolicy — would write ${available.length} local refs`); 172 consola.info(` defaultLightTheme: ${lightUri}`); 173 consola.info(` defaultDarkTheme: ${darkUri}`); 174 } else { 175 await agent.com.atproto.repo.putRecord({ 176 repo: did, 177 collection: "space.atbb.forum.themePolicy", 178 rkey: "self", 179 record: { 180 $type: "space.atbb.forum.themePolicy", 181 availableThemes: available, 182 defaultLightTheme: { uri: lightUri }, 183 defaultDarkTheme: { uri: darkUri }, 184 allowUserChoice: true, 185 updatedAt: now, 186 }, 187 }); 188 consola.success("themePolicy written"); 189 consola.info(` defaultLightTheme: ${lightUri}`); 190 consola.info(` defaultDarkTheme: ${darkUri}`); 191 } 192 193 await forumAgent.shutdown(); 194 consola.log(""); 195 consola.box( 196 "Done — this forum now uses only local preset refs (no atbb.space dependency).\n" + 197 "You can still customize presets in the admin theme editor." 198 ); 199 }, 200}); 201 202// ── publish-canonical ──────────────────────────────────────────────────────── 203 204const publishCanonicalCommand = defineCommand({ 205 meta: { 206 name: "publish-canonical", 207 description: 208 "[atbb.space only] Publish built-in preset themes to the canonical PDS. " + 209 "Safe to re-run — uses upsert semantics, skips unchanged presets.", 210 }, 211 args: { 212 "dry-run": { 213 type: "boolean", 214 description: "Show what would be written without making any changes", 215 default: false, 216 }, 217 }, 218 async run({ args }) { 219 const isDryRun = args["dry-run"]; 220 consola.box("atBB — Publish Canonical Presets" + (isDryRun ? " [dry-run]" : "")); 221 222 const config = loadCliConfig(); 223 const { agent, did, forumAgent } = await authenticate(config); 224 225 consola.info(`Publishing ${PRESETS.length} presets to ${config.pdsUrl}`); 226 if (isDryRun) consola.warn("Dry-run: no changes will be made."); 227 consola.log(""); 228 229 const now = new Date().toISOString(); 230 let written = 0; 231 let skipped = 0; 232 233 for (const preset of PRESETS) { 234 const tokens = JSON.parse( 235 readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 236 ) as Record<string, string>; 237 238 // Fetch existing record to check for changes and preserve createdAt 239 let existingCreatedAt: string | null = null; 240 let alreadyCurrent = false; 241 242 try { 243 const res = await agent.com.atproto.repo.getRecord({ 244 repo: did, 245 collection: "space.atbb.forum.theme", 246 rkey: preset.rkey, 247 }); 248 const existing = res.data.value as Record<string, unknown>; 249 existingCreatedAt = (existing.createdAt as string) ?? null; 250 if (isRecordCurrent(existing, preset, tokens)) alreadyCurrent = true; 251 } catch (err: unknown) { 252 // Only swallow 404 — record doesn't exist yet and will be created. 253 // Re-throw anything else (network errors, auth failures, etc.). 254 const status = (err as Record<string, unknown>).status; 255 if (status !== 404) throw err; 256 } 257 258 if (alreadyCurrent) { 259 consola.success(`${preset.name} — unchanged`); 260 skipped++; 261 continue; 262 } 263 264 const action = existingCreatedAt ? "updated" : "created"; 265 266 if (isDryRun) { 267 consola.info(` ~ ${preset.name} — would ${action}`); 268 written++; 269 continue; 270 } 271 272 const record: Record<string, unknown> = { 273 $type: "space.atbb.forum.theme", 274 name: preset.name, 275 colorScheme: preset.colorScheme, 276 tokens, 277 createdAt: existingCreatedAt ?? now, 278 }; 279 // Only set updatedAt on updates, not initial creates 280 if (existingCreatedAt) record.updatedAt = now; 281 282 await agent.com.atproto.repo.putRecord({ 283 repo: did, 284 collection: "space.atbb.forum.theme", 285 rkey: preset.rkey, 286 record, 287 }); 288 consola.success(`${preset.name}${action}`); 289 written++; 290 } 291 292 await forumAgent.shutdown(); 293 consola.log(""); 294 consola.info(`Done. ${written} written, ${skipped} unchanged.`); 295 }, 296}); 297 298// ── theme command group ────────────────────────────────────────────────────── 299 300export const themeCommand = defineCommand({ 301 meta: { 302 name: "theme", 303 description: "Manage forum themes and preset publishing", 304 }, 305 subCommands: { 306 "bootstrap-local": bootstrapLocalCommand, 307 "publish-canonical": publishCanonicalCommand, 308 }, 309});