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 atb-46-modlog-endpoint 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});