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 { input, confirm } from "@inquirer/prompts";
4import postgres from "postgres";
5import { drizzle } from "drizzle-orm/postgres-js";
6import * as schema from "@atbb/db";
7import { ForumAgent, resolveIdentity } from "@atbb/atproto";
8import { loadCliConfig } from "../lib/config.js";
9import { checkEnvironment } from "../lib/preflight.js";
10import { createForumRecord } from "../lib/steps/create-forum.js";
11import { seedDefaultRoles } from "../lib/steps/seed-roles.js";
12import { assignOwnerRole } from "../lib/steps/assign-owner.js";
13import { categories } from "@atbb/db";
14import { eq, and } from "drizzle-orm";
15import { createCategory } from "../lib/steps/create-category.js";
16import { createBoard } from "../lib/steps/create-board.js";
17import { isProgrammingError } from "../lib/errors.js";
18import { logger } from "../lib/logger.js";
19
20export const initCommand = defineCommand({
21 meta: {
22 name: "init",
23 description: "Bootstrap a new atBB forum instance",
24 },
25 args: {
26 "forum-name": {
27 type: "string",
28 description: "Forum name",
29 },
30 "forum-description": {
31 type: "string",
32 description: "Forum description",
33 },
34 owner: {
35 type: "string",
36 description: "Owner handle or DID (e.g., alice.bsky.social or did:plc:abc123)",
37 },
38 },
39 async run({ args }) {
40 consola.box("atBB Forum Setup");
41
42 // Step 0: Preflight checks
43 consola.start("Checking environment...");
44 const config = loadCliConfig();
45 const envCheck = checkEnvironment(config);
46
47 if (!envCheck.ok) {
48 consola.error("Missing required environment variables:");
49 for (const name of envCheck.errors) {
50 consola.error(` - ${name}`);
51 }
52 consola.info("Set these in your .env file or environment, then re-run.");
53 process.exit(1);
54 }
55
56 consola.success(`DATABASE_URL configured`);
57 consola.success(`FORUM_DID: ${config.forumDid}`);
58 consola.success(`PDS_URL: ${config.pdsUrl}`);
59 consola.success(`FORUM_HANDLE / FORUM_PASSWORD configured`);
60
61 // Step 1: Connect to database
62 // Create the postgres client directly so we can close it on exit.
63 consola.start("Connecting to database...");
64 const sql = postgres(config.databaseUrl);
65 const db = drizzle(sql, { schema });
66
67 async function cleanup() {
68 await sql.end();
69 }
70
71 try {
72 await sql`SELECT 1`;
73 consola.success("Database connection successful");
74 } catch (error) {
75 consola.error("Failed to connect to database:", error instanceof Error ? error.message : String(error));
76 consola.info("Check your DATABASE_URL and ensure the database is running.");
77 await cleanup();
78 process.exit(1);
79 }
80
81 // Step 2: Authenticate as Forum DID
82 consola.start("Authenticating as Forum DID...");
83 const forumAgent = new ForumAgent(config.pdsUrl, config.forumHandle, config.forumPassword, logger);
84 await forumAgent.initialize();
85
86 if (!forumAgent.isAuthenticated()) {
87 const status = forumAgent.getStatus();
88 consola.error(`Failed to authenticate: ${status.error}`);
89 if (status.status === "failed") {
90 consola.info("Check your FORUM_HANDLE and FORUM_PASSWORD.");
91 }
92 await forumAgent.shutdown();
93 await cleanup();
94 process.exit(1);
95 }
96
97 const agent = forumAgent.getAgent()!;
98 consola.success(`Authenticated as ${config.forumHandle}`);
99
100 // Step 3: Create forum record (PDS + DB)
101 consola.log("");
102 consola.info("Step 1: Create Forum Record");
103
104 const forumName = args["forum-name"] ?? await input({
105 message: "Forum name:",
106 default: "My Forum",
107 });
108
109 const forumDescription = args["forum-description"] ?? await input({
110 message: "Forum description (optional):",
111 });
112
113 try {
114 const forumResult = await createForumRecord(db, agent, config.forumDid, {
115 name: forumName,
116 ...(forumDescription && { description: forumDescription }),
117 });
118
119 if (forumResult.skipped) {
120 consola.warn(`Forum record already exists: "${forumResult.existingName}"`);
121 } else {
122 consola.success(`Created forum record: ${forumResult.uri}`);
123 }
124 } catch (error) {
125 consola.error("Failed to create forum record:", error instanceof Error ? error.message : String(error));
126 await forumAgent.shutdown();
127 await cleanup();
128 process.exit(1);
129 }
130
131 // Step 4: Seed default roles (PDS + DB)
132 consola.log("");
133 consola.info("Step 2: Seed Default Roles");
134
135 let seededRoles;
136 try {
137 const rolesResult = await seedDefaultRoles(db, agent, config.forumDid);
138 seededRoles = rolesResult.roles;
139 if (rolesResult.created > 0) {
140 consola.success(`Created ${rolesResult.created} role(s)`);
141 }
142 if (rolesResult.skipped > 0) {
143 consola.warn(`Skipped ${rolesResult.skipped} existing role(s)`);
144 }
145 } catch (error) {
146 consola.error("Failed to seed roles:", error instanceof Error ? error.message : String(error));
147 await forumAgent.shutdown();
148 await cleanup();
149 process.exit(1);
150 }
151
152 // Step 5: Assign owner (DB only — no PDS write since we lack user credentials)
153 consola.log("");
154 consola.info("Step 3: Assign Forum Owner");
155
156 const ownerInput = args.owner ?? await input({
157 message: "Owner handle or DID:",
158 });
159
160 try {
161 consola.start("Resolving identity...");
162 const identity = await resolveIdentity(ownerInput, config.pdsUrl);
163
164 if (identity.handle) {
165 consola.success(`Resolved ${identity.handle} to ${identity.did}`);
166 }
167
168 const ownerResult = await assignOwnerRole(
169 db, config.forumDid, identity.did, identity.handle, seededRoles
170 );
171
172 if (ownerResult.skipped) {
173 consola.warn(`${ownerInput} already has the Owner role`);
174 } else {
175 consola.success(`Assigned Owner role to ${ownerInput}`);
176 }
177 } catch (error) {
178 consola.error("Failed to assign owner:", error instanceof Error ? error.message : String(error));
179 await forumAgent.shutdown();
180 await cleanup();
181 process.exit(1);
182 }
183
184 // Step 4: Seed initial categories and boards (optional)
185 consola.log("");
186 consola.info("Step 4: Seed Initial Structure");
187
188 const shouldSeed = await confirm({
189 message: "Seed an initial category and board?",
190 default: true,
191 });
192
193 if (shouldSeed) {
194 const categoryName = await input({
195 message: "Category name:",
196 default: "General",
197 });
198
199 const categoryDescription = await input({
200 message: "Category description (optional):",
201 });
202
203 let categoryUri: string | undefined;
204 let categoryId: bigint | undefined;
205 let categoryCid: string | undefined;
206
207 try {
208 const categoryResult = await createCategory(db, agent, config.forumDid, {
209 name: categoryName,
210 ...(categoryDescription && { description: categoryDescription }),
211 });
212
213 if (categoryResult.skipped) {
214 consola.warn(`Category "${categoryResult.existingName}" already exists`);
215 } else {
216 consola.success(`Created category "${categoryName}": ${categoryResult.uri}`);
217 }
218
219 categoryUri = categoryResult.uri;
220 categoryCid = categoryResult.cid;
221 } catch (error) {
222 if (isProgrammingError(error)) throw error;
223 consola.error(
224 "Failed to create category:",
225 JSON.stringify({
226 name: categoryName,
227 forumDid: config.forumDid,
228 error: error instanceof Error ? error.message : String(error),
229 })
230 );
231 await forumAgent.shutdown();
232 await cleanup();
233 process.exit(1);
234 }
235
236 // Look up the categoryId from DB separately so a re-query failure doesn't
237 // report as "Failed to create category" (the PDS write already succeeded above)
238 try {
239 const parts = categoryUri!.split("/");
240 const rkey = parts[parts.length - 1];
241 const [cat] = await db
242 .select()
243 .from(categories)
244 .where(and(eq(categories.did, config.forumDid), eq(categories.rkey, rkey)))
245 .limit(1);
246 categoryId = cat?.id;
247 } catch (error) {
248 if (isProgrammingError(error)) throw error;
249 consola.error(
250 "Failed to look up category ID after creation:",
251 JSON.stringify({
252 categoryUri,
253 forumDid: config.forumDid,
254 error: error instanceof Error ? error.message : String(error),
255 })
256 );
257 await forumAgent.shutdown();
258 await cleanup();
259 process.exit(1);
260 }
261
262 if (!categoryId) {
263 consola.error("Failed to look up category ID after creation. Cannot create board.");
264 await forumAgent.shutdown();
265 await cleanup();
266 process.exit(1);
267 }
268
269 // At this point categoryUri, categoryId, and categoryCid are guaranteed set
270 // (the !categoryId guard above exits the process if the DB lookup fails)
271 const boardName = await input({
272 message: "Board name:",
273 default: "General Discussion",
274 });
275
276 const boardDescription = await input({
277 message: "Board description (optional):",
278 });
279
280 try {
281 const boardResult = await createBoard(db, agent, config.forumDid, {
282 name: boardName,
283 ...(boardDescription && { description: boardDescription }),
284 categoryUri: categoryUri!,
285 categoryId: categoryId!,
286 categoryCid: categoryCid!,
287 });
288
289 if (boardResult.skipped) {
290 consola.warn(`Board "${boardResult.existingName}" already exists`);
291 } else {
292 consola.success(`Created board "${boardName}": ${boardResult.uri}`);
293 }
294 } catch (error) {
295 if (isProgrammingError(error)) throw error;
296 consola.error(
297 "Failed to create board:",
298 JSON.stringify({
299 name: boardName,
300 categoryUri,
301 forumDid: config.forumDid,
302 error: error instanceof Error ? error.message : String(error),
303 })
304 );
305 await forumAgent.shutdown();
306 await cleanup();
307 process.exit(1);
308 }
309 } else {
310 consola.info("Skipped. Add categories later with: atbb category add");
311 }
312
313 // Done — close connections
314 await forumAgent.shutdown();
315 await cleanup();
316
317 consola.log("");
318 consola.box({
319 title: "Forum bootstrap complete!",
320 message: [
321 "Next steps:",
322 " 1. Start the appview: pnpm --filter @atbb/appview dev",
323 " 2. Start the web UI: pnpm --filter @atbb/web dev",
324 ` 3. Log in as ${ownerInput} to access admin features`,
325 " 4. Add more boards: atbb board add",
326 " 5. Add more categories: atbb category add",
327 ].join("\n"),
328 });
329 },
330});