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