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 root/atb-56-theme-caching-layer 257 lines 7.8 kB view raw
1import { defineCommand } from "citty"; 2import consola from "consola"; 3import { input, select } from "@inquirer/prompts"; 4import postgres from "postgres"; 5import { drizzle } from "drizzle-orm/postgres-js"; 6import * as schema from "@atbb/db"; 7import { categories } from "@atbb/db"; 8import { eq, and } from "drizzle-orm"; 9import { ForumAgent } from "@atbb/atproto"; 10import { loadCliConfig } from "../lib/config.js"; 11import { checkEnvironment } from "../lib/preflight.js"; 12import { createBoard } from "../lib/steps/create-board.js"; 13import { isProgrammingError } from "../lib/errors.js"; 14import { logger } from "../lib/logger.js"; 15 16const boardAddCommand = defineCommand({ 17 meta: { 18 name: "add", 19 description: "Add a new board within a category", 20 }, 21 args: { 22 "category-uri": { 23 type: "string", 24 description: "AT URI of the parent category (e.g. at://did/space.atbb.forum.category/rkey)", 25 }, 26 name: { 27 type: "string", 28 description: "Board name", 29 }, 30 description: { 31 type: "string", 32 description: "Board description (optional)", 33 }, 34 slug: { 35 type: "string", 36 description: "URL-friendly identifier (auto-derived from name if omitted)", 37 }, 38 "sort-order": { 39 type: "string", 40 description: "Numeric sort position — lower values appear first", 41 }, 42 }, 43 async run({ args }) { 44 consola.box("atBB — Add Board"); 45 46 const config = loadCliConfig(); 47 const envCheck = checkEnvironment(config); 48 49 if (!envCheck.ok) { 50 consola.error("Missing required environment variables:"); 51 for (const name of envCheck.errors) { 52 consola.error(` - ${name}`); 53 } 54 consola.info("Set these in your .env file or environment, then re-run."); 55 process.exit(1); 56 } 57 58 const sql = postgres(config.databaseUrl); 59 const db = drizzle(sql, { schema }); 60 61 async function cleanup() { 62 await sql.end(); 63 } 64 65 try { 66 await sql`SELECT 1`; 67 consola.success("Database connection successful"); 68 } catch (error) { 69 consola.error( 70 "Failed to connect to database:", 71 error instanceof Error ? error.message : String(error) 72 ); 73 await cleanup(); 74 process.exit(1); 75 } 76 77 consola.start("Authenticating as Forum DID..."); 78 const forumAgent = new ForumAgent( 79 config.pdsUrl, 80 config.forumHandle, 81 config.forumPassword, 82 logger 83 ); 84 try { 85 await forumAgent.initialize(); 86 } catch (error) { 87 consola.error( 88 "Failed to reach PDS during authentication:", 89 error instanceof Error ? error.message : String(error) 90 ); 91 try { await forumAgent.shutdown(); } catch {} 92 await cleanup(); 93 process.exit(1); 94 } 95 96 if (!forumAgent.isAuthenticated()) { 97 const status = forumAgent.getStatus(); 98 consola.error(`Failed to authenticate: ${status.error}`); 99 await forumAgent.shutdown(); 100 await cleanup(); 101 process.exit(1); 102 } 103 104 const agent = forumAgent.getAgent()!; 105 consola.success(`Authenticated as ${config.forumHandle}`); 106 107 // Resolve parent category 108 let categoryUri: string; 109 let categoryId: bigint; 110 let categoryCid: string; 111 112 try { 113 if (args["category-uri"]) { 114 // Validate AT URI format before parsing 115 const uri = args["category-uri"]; 116 const parts = uri.split("/"); 117 if (!uri.startsWith("at://") || parts.length < 5) { 118 consola.error(`Invalid AT URI format: ${uri}`); 119 consola.info("Expected format: at://did/space.atbb.forum.category/rkey"); 120 await forumAgent.shutdown(); 121 await cleanup(); 122 process.exit(1); 123 } 124 125 // Validate that the collection segment is the expected category collection 126 if (parts[3] !== "space.atbb.forum.category") { 127 consola.error(`Invalid collection in URI: expected space.atbb.forum.category, got ${parts[3]}`); 128 consola.info("Expected format: at://did/space.atbb.forum.category/rkey"); 129 await forumAgent.shutdown(); 130 await cleanup(); 131 process.exit(1); 132 } 133 134 // Validate by looking it up in the DB 135 // Parse AT URI: at://{did}/{collection}/{rkey} 136 const did = parts[2]; 137 const rkey = parts[parts.length - 1]; 138 139 const [found] = await db 140 .select() 141 .from(categories) 142 .where(and(eq(categories.did, did), eq(categories.rkey, rkey))) 143 .limit(1); 144 145 if (!found) { 146 consola.error(`Category not found: ${uri}`); 147 consola.info("Create it first with: atbb category add"); 148 await forumAgent.shutdown(); 149 await cleanup(); 150 process.exit(1); 151 } 152 153 categoryUri = uri; 154 categoryId = found.id; 155 categoryCid = found.cid; 156 } else { 157 // Interactive selection from all categories in the forum 158 const allCategories = await db 159 .select() 160 .from(categories) 161 .where(eq(categories.did, config.forumDid)) 162 .limit(100); 163 164 if (allCategories.length === 0) { 165 consola.error("No categories found in the database."); 166 consola.info("Create one first with: atbb category add"); 167 await forumAgent.shutdown(); 168 await cleanup(); 169 process.exit(1); 170 } 171 172 const chosen = await select({ 173 message: "Select parent category:", 174 choices: allCategories.map((c) => ({ 175 name: c.description ? `${c.name}${c.description}` : c.name, 176 value: c, 177 })), 178 }); 179 180 categoryUri = `at://${chosen.did}/space.atbb.forum.category/${chosen.rkey}`; 181 categoryId = chosen.id; 182 categoryCid = chosen.cid; 183 } 184 } catch (error) { 185 if (isProgrammingError(error)) throw error; 186 consola.error( 187 "Failed to resolve parent category:", 188 JSON.stringify({ 189 categoryUri: args["category-uri"], 190 forumDid: config.forumDid, 191 error: error instanceof Error ? error.message : String(error), 192 }) 193 ); 194 await forumAgent.shutdown(); 195 await cleanup(); 196 process.exit(1); 197 } 198 199 const name = 200 args.name ?? 201 (await input({ message: "Board name:", default: "General Discussion" })); 202 203 const description = 204 args.description ?? 205 (await input({ message: "Board description (optional):" })); 206 207 const sortOrderRaw = args["sort-order"]; 208 const sortOrder = 209 sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined; 210 211 try { 212 const result = await createBoard(db, agent, config.forumDid, { 213 name, 214 ...(description && { description }), 215 ...(args.slug && { slug: args.slug }), 216 ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }), 217 categoryUri, 218 categoryId, 219 categoryCid, 220 }); 221 222 if (result.skipped) { 223 consola.warn(`Board "${result.existingName}" already exists: ${result.uri}`); 224 } else { 225 consola.success(`Created board "${name}"`); 226 consola.info(`URI: ${result.uri}`); 227 } 228 } catch (error) { 229 if (isProgrammingError(error)) throw error; 230 consola.error( 231 "Failed to create board:", 232 JSON.stringify({ 233 name: args.name, 234 categoryUri, 235 forumDid: config.forumDid, 236 error: error instanceof Error ? error.message : String(error), 237 }) 238 ); 239 await forumAgent.shutdown(); 240 await cleanup(); 241 process.exit(1); 242 } 243 244 await forumAgent.shutdown(); 245 await cleanup(); 246 }, 247}); 248 249export const boardCommand = defineCommand({ 250 meta: { 251 name: "board", 252 description: "Manage forum boards", 253 }, 254 subCommands: { 255 add: boardAddCommand, 256 }, 257});