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