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 9c5d748d4bb3f4e042cb011982af61445fde5ac9 330 lines 11 kB view raw
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});