import { defineCommand } from "citty"; import consola from "consola"; import { input, confirm } from "@inquirer/prompts"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import * as schema from "@atbb/db"; import { ForumAgent, resolveIdentity } from "@atbb/atproto"; import { loadCliConfig } from "../lib/config.js"; import { checkEnvironment } from "../lib/preflight.js"; import { createForumRecord } from "../lib/steps/create-forum.js"; import { seedDefaultRoles } from "../lib/steps/seed-roles.js"; import { assignOwnerRole } from "../lib/steps/assign-owner.js"; import { categories } from "@atbb/db"; import { eq, and } from "drizzle-orm"; import { createCategory } from "../lib/steps/create-category.js"; import { createBoard } from "../lib/steps/create-board.js"; import { isProgrammingError } from "../lib/errors.js"; import { logger } from "../lib/logger.js"; export const initCommand = defineCommand({ meta: { name: "init", description: "Bootstrap a new atBB forum instance", }, args: { "forum-name": { type: "string", description: "Forum name", }, "forum-description": { type: "string", description: "Forum description", }, owner: { type: "string", description: "Owner handle or DID (e.g., alice.bsky.social or did:plc:abc123)", }, }, async run({ args }) { consola.box("atBB Forum Setup"); // Step 0: Preflight checks consola.start("Checking environment..."); 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); } consola.success(`DATABASE_URL configured`); consola.success(`FORUM_DID: ${config.forumDid}`); consola.success(`PDS_URL: ${config.pdsUrl}`); consola.success(`FORUM_HANDLE / FORUM_PASSWORD configured`); // Step 1: Connect to database // Create the postgres client directly so we can close it on exit. consola.start("Connecting to database..."); 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)); consola.info("Check your DATABASE_URL and ensure the database is running."); await cleanup(); process.exit(1); } // Step 2: Authenticate as Forum DID consola.start("Authenticating as Forum DID..."); const forumAgent = new ForumAgent(config.pdsUrl, config.forumHandle, config.forumPassword, logger); await forumAgent.initialize(); if (!forumAgent.isAuthenticated()) { const status = forumAgent.getStatus(); consola.error(`Failed to authenticate: ${status.error}`); if (status.status === "failed") { consola.info("Check your FORUM_HANDLE and FORUM_PASSWORD."); } await forumAgent.shutdown(); await cleanup(); process.exit(1); } const agent = forumAgent.getAgent()!; consola.success(`Authenticated as ${config.forumHandle}`); // Step 3: Create forum record (PDS + DB) consola.log(""); consola.info("Step 1: Create Forum Record"); const forumName = args["forum-name"] ?? await input({ message: "Forum name:", default: "My Forum", }); const forumDescription = args["forum-description"] ?? await input({ message: "Forum description (optional):", }); try { const forumResult = await createForumRecord(db, agent, config.forumDid, { name: forumName, ...(forumDescription && { description: forumDescription }), }); if (forumResult.skipped) { consola.warn(`Forum record already exists: "${forumResult.existingName}"`); } else { consola.success(`Created forum record: ${forumResult.uri}`); } } catch (error) { consola.error("Failed to create forum record:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); await cleanup(); process.exit(1); } // Step 4: Seed default roles (PDS + DB) consola.log(""); consola.info("Step 2: Seed Default Roles"); let seededRoles; try { const rolesResult = await seedDefaultRoles(db, agent, config.forumDid); seededRoles = rolesResult.roles; if (rolesResult.created > 0) { consola.success(`Created ${rolesResult.created} role(s)`); } if (rolesResult.skipped > 0) { consola.warn(`Skipped ${rolesResult.skipped} existing role(s)`); } } catch (error) { consola.error("Failed to seed roles:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); await cleanup(); process.exit(1); } // Step 5: Assign owner (DB only — no PDS write since we lack user credentials) consola.log(""); consola.info("Step 3: Assign Forum Owner"); const ownerInput = args.owner ?? await input({ message: "Owner handle or DID:", }); try { consola.start("Resolving identity..."); const identity = await resolveIdentity(ownerInput, config.pdsUrl); if (identity.handle) { consola.success(`Resolved ${identity.handle} to ${identity.did}`); } const ownerResult = await assignOwnerRole( db, config.forumDid, identity.did, identity.handle, seededRoles ); if (ownerResult.skipped) { consola.warn(`${ownerInput} already has the Owner role`); } else { consola.success(`Assigned Owner role to ${ownerInput}`); } } catch (error) { consola.error("Failed to assign owner:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); await cleanup(); process.exit(1); } // Step 4: Seed initial categories and boards (optional) consola.log(""); consola.info("Step 4: Seed Initial Structure"); const shouldSeed = await confirm({ message: "Seed an initial category and board?", default: true, }); if (shouldSeed) { const categoryName = await input({ message: "Category name:", default: "General", }); const categoryDescription = await input({ message: "Category description (optional):", }); let categoryUri: string | undefined; let categoryId: bigint | undefined; let categoryCid: string | undefined; try { const categoryResult = await createCategory(db, agent, config.forumDid, { name: categoryName, ...(categoryDescription && { description: categoryDescription }), }); if (categoryResult.skipped) { consola.warn(`Category "${categoryResult.existingName}" already exists`); } else { consola.success(`Created category "${categoryName}": ${categoryResult.uri}`); } categoryUri = categoryResult.uri; categoryCid = categoryResult.cid; } catch (error) { if (isProgrammingError(error)) throw error; consola.error( "Failed to create category:", JSON.stringify({ name: categoryName, forumDid: config.forumDid, error: error instanceof Error ? error.message : String(error), }) ); await forumAgent.shutdown(); await cleanup(); process.exit(1); } // Look up the categoryId from DB separately so a re-query failure doesn't // report as "Failed to create category" (the PDS write already succeeded above) try { const parts = categoryUri!.split("/"); const rkey = parts[parts.length - 1]; const [cat] = await db .select() .from(categories) .where(and(eq(categories.did, config.forumDid), eq(categories.rkey, rkey))) .limit(1); categoryId = cat?.id; } catch (error) { if (isProgrammingError(error)) throw error; consola.error( "Failed to look up category ID after creation:", JSON.stringify({ categoryUri, forumDid: config.forumDid, error: error instanceof Error ? error.message : String(error), }) ); await forumAgent.shutdown(); await cleanup(); process.exit(1); } if (!categoryId) { consola.error("Failed to look up category ID after creation. Cannot create board."); await forumAgent.shutdown(); await cleanup(); process.exit(1); } // At this point categoryUri, categoryId, and categoryCid are guaranteed set // (the !categoryId guard above exits the process if the DB lookup fails) const boardName = await input({ message: "Board name:", default: "General Discussion", }); const boardDescription = await input({ message: "Board description (optional):", }); try { const boardResult = await createBoard(db, agent, config.forumDid, { name: boardName, ...(boardDescription && { description: boardDescription }), categoryUri: categoryUri!, categoryId: categoryId!, categoryCid: categoryCid!, }); if (boardResult.skipped) { consola.warn(`Board "${boardResult.existingName}" already exists`); } else { consola.success(`Created board "${boardName}": ${boardResult.uri}`); } } catch (error) { if (isProgrammingError(error)) throw error; consola.error( "Failed to create board:", JSON.stringify({ name: boardName, categoryUri, forumDid: config.forumDid, error: error instanceof Error ? error.message : String(error), }) ); await forumAgent.shutdown(); await cleanup(); process.exit(1); } } else { consola.info("Skipped. Add categories later with: atbb category add"); } // Done — close connections await forumAgent.shutdown(); await cleanup(); consola.log(""); consola.box({ title: "Forum bootstrap complete!", message: [ "Next steps:", " 1. Start the appview: pnpm --filter @atbb/appview dev", " 2. Start the web UI: pnpm --filter @atbb/web dev", ` 3. Log in as ${ownerInput} to access admin features`, " 4. Add more boards: atbb board add", " 5. Add more categories: atbb category add", ].join("\n"), }); }, });