import { defineCommand } from "citty"; import consola from "consola"; import { input, select } from "@inquirer/prompts"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import * as schema from "@atbb/db"; import { categories } from "@atbb/db"; import { eq, and } from "drizzle-orm"; import { ForumAgent } from "@atbb/atproto"; import { loadCliConfig } from "../lib/config.js"; import { checkEnvironment } from "../lib/preflight.js"; import { createBoard } from "../lib/steps/create-board.js"; import { isProgrammingError } from "../lib/errors.js"; import { logger } from "../lib/logger.js"; const boardAddCommand = defineCommand({ meta: { name: "add", description: "Add a new board within a category", }, args: { "category-uri": { type: "string", description: "AT URI of the parent category (e.g. at://did/space.atbb.forum.category/rkey)", }, name: { type: "string", description: "Board name", }, description: { type: "string", description: "Board description (optional)", }, slug: { type: "string", description: "URL-friendly identifier (auto-derived from name if omitted)", }, "sort-order": { type: "string", description: "Numeric sort position — lower values appear first", }, }, async run({ args }) { consola.box("atBB — Add Board"); const config = loadCliConfig(); const envCheck = checkEnvironment(config); if (!envCheck.ok) { consola.error("Missing required environment variables:"); for (const name of envCheck.errors) { consola.error(` - ${name}`); } consola.info("Set these in your .env file or environment, then re-run."); process.exit(1); } const sql = postgres(config.databaseUrl); const db = drizzle(sql, { schema }); async function cleanup() { await sql.end(); } try { await sql`SELECT 1`; consola.success("Database connection successful"); } catch (error) { consola.error( "Failed to connect to database:", error instanceof Error ? error.message : String(error) ); await cleanup(); process.exit(1); } consola.start("Authenticating as Forum DID..."); const forumAgent = new ForumAgent( config.pdsUrl, config.forumHandle, config.forumPassword, logger ); try { await forumAgent.initialize(); } catch (error) { consola.error( "Failed to reach PDS during authentication:", error instanceof Error ? error.message : String(error) ); try { await forumAgent.shutdown(); } catch {} await cleanup(); process.exit(1); } if (!forumAgent.isAuthenticated()) { const status = forumAgent.getStatus(); consola.error(`Failed to authenticate: ${status.error}`); await forumAgent.shutdown(); await cleanup(); process.exit(1); } const agent = forumAgent.getAgent()!; consola.success(`Authenticated as ${config.forumHandle}`); // Resolve parent category let categoryUri: string; let categoryId: bigint; let categoryCid: string; try { if (args["category-uri"]) { // Validate AT URI format before parsing const uri = args["category-uri"]; const parts = uri.split("/"); if (!uri.startsWith("at://") || parts.length < 5) { consola.error(`Invalid AT URI format: ${uri}`); consola.info("Expected format: at://did/space.atbb.forum.category/rkey"); await forumAgent.shutdown(); await cleanup(); process.exit(1); } // Validate that the collection segment is the expected category collection if (parts[3] !== "space.atbb.forum.category") { consola.error(`Invalid collection in URI: expected space.atbb.forum.category, got ${parts[3]}`); consola.info("Expected format: at://did/space.atbb.forum.category/rkey"); await forumAgent.shutdown(); await cleanup(); process.exit(1); } // Validate by looking it up in the DB // Parse AT URI: at://{did}/{collection}/{rkey} const did = parts[2]; const rkey = parts[parts.length - 1]; const [found] = await db .select() .from(categories) .where(and(eq(categories.did, did), eq(categories.rkey, rkey))) .limit(1); if (!found) { consola.error(`Category not found: ${uri}`); consola.info("Create it first with: atbb category add"); await forumAgent.shutdown(); await cleanup(); process.exit(1); } categoryUri = uri; categoryId = found.id; categoryCid = found.cid; } else { // Interactive selection from all categories in the forum const allCategories = await db .select() .from(categories) .where(eq(categories.did, config.forumDid)) .limit(100); if (allCategories.length === 0) { consola.error("No categories found in the database."); consola.info("Create one first with: atbb category add"); await forumAgent.shutdown(); await cleanup(); process.exit(1); } const chosen = await select({ message: "Select parent category:", choices: allCategories.map((c) => ({ name: c.description ? `${c.name} — ${c.description}` : c.name, value: c, })), }); categoryUri = `at://${chosen.did}/space.atbb.forum.category/${chosen.rkey}`; categoryId = chosen.id; categoryCid = chosen.cid; } } catch (error) { if (isProgrammingError(error)) throw error; consola.error( "Failed to resolve parent category:", JSON.stringify({ categoryUri: args["category-uri"], forumDid: config.forumDid, error: error instanceof Error ? error.message : String(error), }) ); await forumAgent.shutdown(); await cleanup(); process.exit(1); } const name = args.name ?? (await input({ message: "Board name:", default: "General Discussion" })); const description = args.description ?? (await input({ message: "Board description (optional):" })); const sortOrderRaw = args["sort-order"]; const sortOrder = sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined; try { const result = await createBoard(db, agent, config.forumDid, { name, ...(description && { description }), ...(args.slug && { slug: args.slug }), ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }), categoryUri, categoryId, categoryCid, }); if (result.skipped) { consola.warn(`Board "${result.existingName}" already exists: ${result.uri}`); } else { consola.success(`Created board "${name}"`); consola.info(`URI: ${result.uri}`); } } catch (error) { if (isProgrammingError(error)) throw error; consola.error( "Failed to create board:", JSON.stringify({ name: args.name, categoryUri, forumDid: config.forumDid, error: error instanceof Error ? error.message : String(error), }) ); await forumAgent.shutdown(); await cleanup(); process.exit(1); } await forumAgent.shutdown(); await cleanup(); }, }); export const boardCommand = defineCommand({ meta: { name: "board", description: "Manage forum boards", }, subCommands: { add: boardAddCommand, }, });