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