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 main 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});