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
1# CLI: Categories and Boards — Implementation Plan
2
3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
5**Goal:** Extend `@atbb/cli` with `atbb category add` and `atbb board add` commands, plus a seeding step in `atbb init` that optionally creates a starter category and board.
6
7**Architecture:** Two new step modules (`create-category.ts`, `create-board.ts`) follow the exact pattern of `create-forum.ts` — check idempotency, write PDS record, insert DB row. Two new command files (`category.ts`, `board.ts`) expose `atbb category add` and `atbb board add` using citty's nested subcommand structure. `init.ts` gains a Step 4 that calls these step functions with interactive prompts.
8
9**Tech Stack:** TypeScript, citty (CLI routing), consola (output), @inquirer/prompts (interactive input), Drizzle ORM (DB), @atproto/api (PDS writes)
10
11---
12
13## Task 1: `create-category.ts` step module (TDD)
14
15**Files:**
16- Create: `packages/cli/src/__tests__/create-category.test.ts`
17- Create: `packages/cli/src/lib/steps/create-category.ts`
18
19### Step 1: Write the failing tests
20
21Create `packages/cli/src/__tests__/create-category.test.ts`:
22
23```typescript
24import { describe, it, expect, vi } from "vitest";
25import { createCategory } from "../lib/steps/create-category.js";
26
27describe("createCategory", () => {
28 const forumDid = "did:plc:testforum";
29
30 // Builds a mock DB. If existingCategory is set, the first select() returns it.
31 // The second select() (forum lookup) always returns a mock forum row.
32 function mockDb(options: { existingCategory?: any } = {}) {
33 let callCount = 0;
34 return {
35 select: vi.fn().mockImplementation(() => ({
36 from: vi.fn().mockReturnValue({
37 where: vi.fn().mockReturnValue({
38 limit: vi.fn().mockImplementation(() => {
39 callCount++;
40 if (callCount === 1) {
41 // First select: category idempotency check
42 return options.existingCategory ? [options.existingCategory] : [];
43 }
44 // Second select: forum lookup for forumId
45 return [{ id: BigInt(1) }];
46 }),
47 }),
48 }),
49 })),
50 insert: vi.fn().mockReturnValue({
51 values: vi.fn().mockResolvedValue(undefined),
52 }),
53 } as any;
54 }
55
56 function mockAgent(overrides: Record<string, any> = {}) {
57 return {
58 com: {
59 atproto: {
60 repo: {
61 createRecord: vi.fn().mockResolvedValue({
62 data: {
63 uri: `at://${forumDid}/space.atbb.forum.category/tid123`,
64 cid: "bafytest",
65 },
66 }),
67 ...overrides,
68 },
69 },
70 },
71 } as any;
72 }
73
74 it("creates category on PDS and inserts into DB", async () => {
75 const db = mockDb();
76 const agent = mockAgent();
77
78 const result = await createCategory(db, agent, forumDid, {
79 name: "General",
80 description: "General discussion",
81 });
82
83 expect(result.created).toBe(true);
84 expect(result.skipped).toBe(false);
85 expect(result.uri).toContain("space.atbb.forum.category/tid123");
86 expect(result.cid).toBe("bafytest");
87 expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
88 expect.objectContaining({
89 repo: forumDid,
90 collection: "space.atbb.forum.category",
91 record: expect.objectContaining({
92 $type: "space.atbb.forum.category",
93 name: "General",
94 description: "General discussion",
95 }),
96 })
97 );
98 expect(db.insert).toHaveBeenCalled();
99 });
100
101 it("derives slug from name when not provided", async () => {
102 const db = mockDb();
103 const agent = mockAgent();
104
105 await createCategory(db, agent, forumDid, { name: "My Cool Category" });
106
107 expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
108 expect.objectContaining({
109 record: expect.objectContaining({ slug: "my-cool-category" }),
110 })
111 );
112 });
113
114 it("uses provided slug instead of deriving one", async () => {
115 const db = mockDb();
116 const agent = mockAgent();
117
118 await createCategory(db, agent, forumDid, { name: "General", slug: "gen" });
119
120 expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
121 expect.objectContaining({
122 record: expect.objectContaining({ slug: "gen" }),
123 })
124 );
125 });
126
127 it("skips when category with same name already exists", async () => {
128 const db = mockDb({
129 existingCategory: {
130 did: forumDid,
131 rkey: "existingtid",
132 cid: "bafyexisting",
133 name: "General",
134 },
135 });
136 const agent = mockAgent();
137
138 const result = await createCategory(db, agent, forumDid, { name: "General" });
139
140 expect(result.created).toBe(false);
141 expect(result.skipped).toBe(true);
142 expect(result.existingName).toBe("General");
143 expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled();
144 expect(db.insert).not.toHaveBeenCalled();
145 });
146
147 it("throws when PDS write fails", async () => {
148 const db = mockDb();
149 const agent = mockAgent({
150 createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")),
151 });
152
153 await expect(
154 createCategory(db, agent, forumDid, { name: "General" })
155 ).rejects.toThrow("PDS write failed");
156 });
157});
158```
159
160### Step 2: Run tests to verify they fail
161
162```sh
163export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
164pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts
165```
166
167Expected: FAIL with "Cannot find module '../lib/steps/create-category.js'"
168
169### Step 3: Implement `create-category.ts`
170
171Create `packages/cli/src/lib/steps/create-category.ts`:
172
173```typescript
174import type { AtpAgent } from "@atproto/api";
175import type { Database } from "@atbb/db";
176import { categories, forums } from "@atbb/db";
177import { eq, and } from "drizzle-orm";
178import { isProgrammingError } from "@atbb/atproto";
179
180interface CreateCategoryInput {
181 name: string;
182 description?: string;
183 slug?: string;
184 sortOrder?: number;
185}
186
187interface CreateCategoryResult {
188 created: boolean;
189 skipped: boolean;
190 uri?: string;
191 cid?: string;
192 existingName?: string;
193}
194
195function deriveSlug(name: string): string {
196 return name
197 .toLowerCase()
198 .replace(/[^a-z0-9]+/g, "-")
199 .replace(/^-|-$/g, "");
200}
201
202/**
203 * Create a space.atbb.forum.category record on the Forum DID's PDS
204 * and insert it into the database.
205 * Idempotent: skips if a category with the same name already exists.
206 */
207export async function createCategory(
208 db: Database,
209 agent: AtpAgent,
210 forumDid: string,
211 input: CreateCategoryInput
212): Promise<CreateCategoryResult> {
213 // Check if category with this name already exists
214 const [existing] = await db
215 .select()
216 .from(categories)
217 .where(and(eq(categories.did, forumDid), eq(categories.name, input.name)))
218 .limit(1);
219
220 if (existing) {
221 return {
222 created: false,
223 skipped: true,
224 uri: `at://${existing.did}/space.atbb.forum.category/${existing.rkey}`,
225 cid: existing.cid,
226 existingName: existing.name,
227 };
228 }
229
230 // Look up forum row for FK reference (optional — null if forum not yet in DB)
231 const [forum] = await db
232 .select()
233 .from(forums)
234 .where(and(eq(forums.did, forumDid), eq(forums.rkey, "self")))
235 .limit(1);
236
237 const slug = input.slug ?? deriveSlug(input.name);
238 const now = new Date();
239
240 let response;
241 try {
242 response = await agent.com.atproto.repo.createRecord({
243 repo: forumDid,
244 collection: "space.atbb.forum.category",
245 record: {
246 $type: "space.atbb.forum.category",
247 name: input.name,
248 ...(input.description && { description: input.description }),
249 slug,
250 ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
251 createdAt: now.toISOString(),
252 },
253 });
254 } catch (error) {
255 if (isProgrammingError(error)) throw error;
256 throw error; // PDS errors bubble up to command handler
257 }
258
259 const rkey = response.data.uri.split("/").pop()!;
260
261 await db.insert(categories).values({
262 did: forumDid,
263 rkey,
264 cid: response.data.cid,
265 name: input.name,
266 description: input.description ?? null,
267 slug,
268 sortOrder: input.sortOrder ?? null,
269 forumId: forum?.id ?? null,
270 createdAt: now,
271 indexedAt: now,
272 });
273
274 return {
275 created: true,
276 skipped: false,
277 uri: response.data.uri,
278 cid: response.data.cid,
279 };
280}
281```
282
283### Step 4: Run tests to verify they pass
284
285```sh
286export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
287pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts
288```
289
290Expected: All 5 tests PASS
291
292### Step 5: Commit
293
294```sh
295git add packages/cli/src/__tests__/create-category.test.ts packages/cli/src/lib/steps/create-category.ts
296git commit -m "feat: add createCategory step module (ATB-28)"
297```
298
299---
300
301## Task 2: `create-board.ts` step module (TDD)
302
303**Files:**
304- Create: `packages/cli/src/__tests__/create-board.test.ts`
305- Create: `packages/cli/src/lib/steps/create-board.ts`
306
307### Step 1: Write the failing tests
308
309Create `packages/cli/src/__tests__/create-board.test.ts`:
310
311```typescript
312import { describe, it, expect, vi } from "vitest";
313import { createBoard } from "../lib/steps/create-board.js";
314
315describe("createBoard", () => {
316 const forumDid = "did:plc:testforum";
317 const categoryUri = `at://${forumDid}/space.atbb.forum.category/cattid`;
318 const categoryId = BigInt(42);
319 const categoryCid = "bafycategory";
320
321 function mockDb(options: { existingBoard?: any } = {}) {
322 return {
323 select: vi.fn().mockReturnValue({
324 from: vi.fn().mockReturnValue({
325 where: vi.fn().mockReturnValue({
326 limit: vi.fn().mockResolvedValue(
327 options.existingBoard ? [options.existingBoard] : []
328 ),
329 }),
330 }),
331 }),
332 insert: vi.fn().mockReturnValue({
333 values: vi.fn().mockResolvedValue(undefined),
334 }),
335 } as any;
336 }
337
338 function mockAgent(overrides: Record<string, any> = {}) {
339 return {
340 com: {
341 atproto: {
342 repo: {
343 createRecord: vi.fn().mockResolvedValue({
344 data: {
345 uri: `at://${forumDid}/space.atbb.forum.board/tid456`,
346 cid: "bafyboard",
347 },
348 }),
349 ...overrides,
350 },
351 },
352 },
353 } as any;
354 }
355
356 const baseInput = {
357 name: "General Discussion",
358 categoryUri,
359 categoryId,
360 categoryCid,
361 };
362
363 it("creates board on PDS and inserts into DB", async () => {
364 const db = mockDb();
365 const agent = mockAgent();
366
367 const result = await createBoard(db, agent, forumDid, baseInput);
368
369 expect(result.created).toBe(true);
370 expect(result.skipped).toBe(false);
371 expect(result.uri).toContain("space.atbb.forum.board/tid456");
372 expect(result.cid).toBe("bafyboard");
373 expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
374 expect.objectContaining({
375 repo: forumDid,
376 collection: "space.atbb.forum.board",
377 record: expect.objectContaining({
378 $type: "space.atbb.forum.board",
379 name: "General Discussion",
380 // Board record includes the category ref nested under "category"
381 category: {
382 category: { uri: categoryUri, cid: categoryCid },
383 },
384 }),
385 })
386 );
387 expect(db.insert).toHaveBeenCalled();
388 });
389
390 it("derives slug from name", async () => {
391 const db = mockDb();
392 const agent = mockAgent();
393
394 await createBoard(db, agent, forumDid, {
395 ...baseInput,
396 name: "Off Topic Chat",
397 });
398
399 expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
400 expect.objectContaining({
401 record: expect.objectContaining({ slug: "off-topic-chat" }),
402 })
403 );
404 });
405
406 it("skips when board with same name exists in the same category", async () => {
407 const db = mockDb({
408 existingBoard: {
409 did: forumDid,
410 rkey: "existingtid",
411 cid: "bafyexisting",
412 name: "General Discussion",
413 },
414 });
415 const agent = mockAgent();
416
417 const result = await createBoard(db, agent, forumDid, baseInput);
418
419 expect(result.created).toBe(false);
420 expect(result.skipped).toBe(true);
421 expect(result.existingName).toBe("General Discussion");
422 expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled();
423 expect(db.insert).not.toHaveBeenCalled();
424 });
425
426 it("throws when PDS write fails", async () => {
427 const db = mockDb();
428 const agent = mockAgent({
429 createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")),
430 });
431
432 await expect(createBoard(db, agent, forumDid, baseInput)).rejects.toThrow(
433 "PDS write failed"
434 );
435 });
436});
437```
438
439### Step 2: Run tests to verify they fail
440
441```sh
442export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
443pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts
444```
445
446Expected: FAIL with "Cannot find module '../lib/steps/create-board.js'"
447
448### Step 3: Implement `create-board.ts`
449
450Create `packages/cli/src/lib/steps/create-board.ts`:
451
452```typescript
453import type { AtpAgent } from "@atproto/api";
454import type { Database } from "@atbb/db";
455import { boards } from "@atbb/db";
456import { eq, and } from "drizzle-orm";
457import { isProgrammingError } from "@atbb/atproto";
458
459interface CreateBoardInput {
460 name: string;
461 description?: string;
462 slug?: string;
463 sortOrder?: number;
464 categoryUri: string; // AT URI: at://did/space.atbb.forum.category/rkey
465 categoryId: bigint; // DB FK
466 categoryCid: string; // CID for the category strongRef
467}
468
469interface CreateBoardResult {
470 created: boolean;
471 skipped: boolean;
472 uri?: string;
473 cid?: string;
474 existingName?: string;
475}
476
477function deriveSlug(name: string): string {
478 return name
479 .toLowerCase()
480 .replace(/[^a-z0-9]+/g, "-")
481 .replace(/^-|-$/g, "");
482}
483
484/**
485 * Create a space.atbb.forum.board record on the Forum DID's PDS
486 * and insert it into the database.
487 * Idempotent: skips if a board with the same name in the same category exists.
488 */
489export async function createBoard(
490 db: Database,
491 agent: AtpAgent,
492 forumDid: string,
493 input: CreateBoardInput
494): Promise<CreateBoardResult> {
495 // Check if board with this name already exists in the category
496 const [existing] = await db
497 .select()
498 .from(boards)
499 .where(
500 and(
501 eq(boards.did, forumDid),
502 eq(boards.name, input.name),
503 eq(boards.categoryUri, input.categoryUri)
504 )
505 )
506 .limit(1);
507
508 if (existing) {
509 return {
510 created: false,
511 skipped: true,
512 uri: `at://${existing.did}/space.atbb.forum.board/${existing.rkey}`,
513 cid: existing.cid,
514 existingName: existing.name,
515 };
516 }
517
518 const slug = input.slug ?? deriveSlug(input.name);
519 const now = new Date();
520
521 let response;
522 try {
523 response = await agent.com.atproto.repo.createRecord({
524 repo: forumDid,
525 collection: "space.atbb.forum.board",
526 record: {
527 $type: "space.atbb.forum.board",
528 name: input.name,
529 ...(input.description && { description: input.description }),
530 slug,
531 ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
532 // categoryRef shape: { category: strongRef }
533 category: {
534 category: {
535 uri: input.categoryUri,
536 cid: input.categoryCid,
537 },
538 },
539 createdAt: now.toISOString(),
540 },
541 });
542 } catch (error) {
543 if (isProgrammingError(error)) throw error;
544 throw error;
545 }
546
547 const rkey = response.data.uri.split("/").pop()!;
548
549 await db.insert(boards).values({
550 did: forumDid,
551 rkey,
552 cid: response.data.cid,
553 name: input.name,
554 description: input.description ?? null,
555 slug,
556 sortOrder: input.sortOrder ?? null,
557 categoryId: input.categoryId,
558 categoryUri: input.categoryUri,
559 createdAt: now,
560 indexedAt: now,
561 });
562
563 return {
564 created: true,
565 skipped: false,
566 uri: response.data.uri,
567 cid: response.data.cid,
568 };
569}
570```
571
572### Step 4: Run tests to verify they pass
573
574```sh
575export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
576pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts
577```
578
579Expected: All 4 tests PASS
580
581### Step 5: Commit
582
583```sh
584git add packages/cli/src/__tests__/create-board.test.ts packages/cli/src/lib/steps/create-board.ts
585git commit -m "feat: add createBoard step module (ATB-28)"
586```
587
588---
589
590## Task 3: `atbb category add` command
591
592**Files:**
593- Create: `packages/cli/src/commands/category.ts`
594
595### Step 1: Implement `category.ts`
596
597Create `packages/cli/src/commands/category.ts`:
598
599```typescript
600import { defineCommand } from "citty";
601import consola from "consola";
602import { input } from "@inquirer/prompts";
603import postgres from "postgres";
604import { drizzle } from "drizzle-orm/postgres-js";
605import * as schema from "@atbb/db";
606import { ForumAgent } from "@atbb/atproto";
607import { loadCliConfig } from "../lib/config.js";
608import { checkEnvironment } from "../lib/preflight.js";
609import { createCategory } from "../lib/steps/create-category.js";
610
611const categoryAddCommand = defineCommand({
612 meta: {
613 name: "add",
614 description: "Add a new category to the forum",
615 },
616 args: {
617 name: {
618 type: "string",
619 description: "Category name",
620 },
621 description: {
622 type: "string",
623 description: "Category description (optional)",
624 },
625 slug: {
626 type: "string",
627 description: "URL-friendly identifier (auto-derived from name if omitted)",
628 },
629 "sort-order": {
630 type: "string",
631 description: "Numeric sort position — lower values appear first",
632 },
633 },
634 async run({ args }) {
635 consola.box("atBB — Add Category");
636
637 const config = loadCliConfig();
638 const envCheck = checkEnvironment(config);
639
640 if (!envCheck.ok) {
641 consola.error("Missing required environment variables:");
642 for (const name of envCheck.errors) {
643 consola.error(` - ${name}`);
644 }
645 consola.info("Set these in your .env file or environment, then re-run.");
646 process.exit(1);
647 }
648
649 const sql = postgres(config.databaseUrl);
650 const db = drizzle(sql, { schema });
651
652 async function cleanup() {
653 await sql.end();
654 }
655
656 try {
657 await sql`SELECT 1`;
658 consola.success("Database connection successful");
659 } catch (error) {
660 consola.error(
661 "Failed to connect to database:",
662 error instanceof Error ? error.message : String(error)
663 );
664 await cleanup();
665 process.exit(1);
666 }
667
668 consola.start("Authenticating as Forum DID...");
669 const forumAgent = new ForumAgent(
670 config.pdsUrl,
671 config.forumHandle,
672 config.forumPassword
673 );
674 await forumAgent.initialize();
675
676 if (!forumAgent.isAuthenticated()) {
677 const status = forumAgent.getStatus();
678 consola.error(`Failed to authenticate: ${status.error}`);
679 await forumAgent.shutdown();
680 await cleanup();
681 process.exit(1);
682 }
683
684 const agent = forumAgent.getAgent()!;
685 consola.success(`Authenticated as ${config.forumHandle}`);
686
687 const name =
688 args.name ??
689 (await input({ message: "Category name:", default: "General" }));
690
691 const description =
692 args.description ??
693 (await input({ message: "Category description (optional):" }));
694
695 const sortOrderRaw = args["sort-order"];
696 const sortOrder =
697 sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined;
698
699 try {
700 const result = await createCategory(db, agent, config.forumDid, {
701 name,
702 ...(description && { description }),
703 ...(args.slug && { slug: args.slug }),
704 ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }),
705 });
706
707 if (result.skipped) {
708 consola.warn(`Category "${result.existingName}" already exists: ${result.uri}`);
709 } else {
710 consola.success(`Created category "${name}"`);
711 consola.info(`URI: ${result.uri}`);
712 }
713 } catch (error) {
714 consola.error(
715 "Failed to create category:",
716 error instanceof Error ? error.message : String(error)
717 );
718 await forumAgent.shutdown();
719 await cleanup();
720 process.exit(1);
721 }
722
723 await forumAgent.shutdown();
724 await cleanup();
725 },
726});
727
728export const categoryCommand = defineCommand({
729 meta: {
730 name: "category",
731 description: "Manage forum categories",
732 },
733 subCommands: {
734 add: categoryAddCommand,
735 },
736});
737```
738
739### Step 2: Register `categoryCommand` in `index.ts`
740
741Open `packages/cli/src/index.ts` and add the import + subcommand entry:
742
743```typescript
744// Add this import (after existing imports):
745import { categoryCommand } from "./commands/category.js";
746
747// Update subCommands:
748subCommands: {
749 init: initCommand,
750 category: categoryCommand, // ← add this line
751},
752```
753
754### Step 3: Build to verify no TypeScript errors
755
756```sh
757export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
758pnpm --filter @atbb/cli lint
759```
760
761Expected: No errors
762
763### Step 4: Commit
764
765```sh
766git add packages/cli/src/commands/category.ts packages/cli/src/index.ts
767git commit -m "feat: add atbb category add command (ATB-28)"
768```
769
770---
771
772## Task 4: `atbb board add` command
773
774**Files:**
775- Create: `packages/cli/src/commands/board.ts`
776- Modify: `packages/cli/src/index.ts`
777
778### Step 1: Implement `board.ts`
779
780Create `packages/cli/src/commands/board.ts`:
781
782```typescript
783import { defineCommand } from "citty";
784import consola from "consola";
785import { input, select } from "@inquirer/prompts";
786import postgres from "postgres";
787import { drizzle } from "drizzle-orm/postgres-js";
788import * as schema from "@atbb/db";
789import { categories } from "@atbb/db";
790import { eq, and } from "drizzle-orm";
791import { ForumAgent } from "@atbb/atproto";
792import { loadCliConfig } from "../lib/config.js";
793import { checkEnvironment } from "../lib/preflight.js";
794import { createBoard } from "../lib/steps/create-board.js";
795
796const boardAddCommand = defineCommand({
797 meta: {
798 name: "add",
799 description: "Add a new board within a category",
800 },
801 args: {
802 "category-uri": {
803 type: "string",
804 description: "AT URI of the parent category (e.g. at://did/space.atbb.forum.category/rkey)",
805 },
806 name: {
807 type: "string",
808 description: "Board name",
809 },
810 description: {
811 type: "string",
812 description: "Board description (optional)",
813 },
814 slug: {
815 type: "string",
816 description: "URL-friendly identifier (auto-derived from name if omitted)",
817 },
818 "sort-order": {
819 type: "string",
820 description: "Numeric sort position — lower values appear first",
821 },
822 },
823 async run({ args }) {
824 consola.box("atBB — Add Board");
825
826 const config = loadCliConfig();
827 const envCheck = checkEnvironment(config);
828
829 if (!envCheck.ok) {
830 consola.error("Missing required environment variables:");
831 for (const name of envCheck.errors) {
832 consola.error(` - ${name}`);
833 }
834 consola.info("Set these in your .env file or environment, then re-run.");
835 process.exit(1);
836 }
837
838 const sql = postgres(config.databaseUrl);
839 const db = drizzle(sql, { schema });
840
841 async function cleanup() {
842 await sql.end();
843 }
844
845 try {
846 await sql`SELECT 1`;
847 consola.success("Database connection successful");
848 } catch (error) {
849 consola.error(
850 "Failed to connect to database:",
851 error instanceof Error ? error.message : String(error)
852 );
853 await cleanup();
854 process.exit(1);
855 }
856
857 consola.start("Authenticating as Forum DID...");
858 const forumAgent = new ForumAgent(
859 config.pdsUrl,
860 config.forumHandle,
861 config.forumPassword
862 );
863 await forumAgent.initialize();
864
865 if (!forumAgent.isAuthenticated()) {
866 const status = forumAgent.getStatus();
867 consola.error(`Failed to authenticate: ${status.error}`);
868 await forumAgent.shutdown();
869 await cleanup();
870 process.exit(1);
871 }
872
873 const agent = forumAgent.getAgent()!;
874 consola.success(`Authenticated as ${config.forumHandle}`);
875
876 // Resolve parent category
877 let categoryUri: string;
878 let categoryId: bigint;
879 let categoryCid: string;
880
881 if (args["category-uri"]) {
882 // Validate by looking it up in the DB
883 // Parse AT URI: at://{did}/{collection}/{rkey}
884 const parts = args["category-uri"].split("/");
885 const did = parts[2];
886 const rkey = parts[parts.length - 1];
887
888 const [found] = await db
889 .select()
890 .from(categories)
891 .where(and(eq(categories.did, did), eq(categories.rkey, rkey)))
892 .limit(1);
893
894 if (!found) {
895 consola.error(`Category not found: ${args["category-uri"]}`);
896 consola.info("Create it first with: atbb category add");
897 await forumAgent.shutdown();
898 await cleanup();
899 process.exit(1);
900 }
901
902 categoryUri = args["category-uri"];
903 categoryId = found.id;
904 categoryCid = found.cid;
905 } else {
906 // Interactive selection from all categories in the forum
907 const allCategories = await db
908 .select()
909 .from(categories)
910 .where(eq(categories.did, config.forumDid))
911 .limit(100);
912
913 if (allCategories.length === 0) {
914 consola.error("No categories found in the database.");
915 consola.info("Create one first with: atbb category add");
916 await forumAgent.shutdown();
917 await cleanup();
918 process.exit(1);
919 }
920
921 const chosen = await select({
922 message: "Select parent category:",
923 choices: allCategories.map((c) => ({
924 name: c.description ? `${c.name} — ${c.description}` : c.name,
925 value: c,
926 })),
927 });
928
929 categoryUri = `at://${chosen.did}/space.atbb.forum.category/${chosen.rkey}`;
930 categoryId = chosen.id;
931 categoryCid = chosen.cid;
932 }
933
934 const name =
935 args.name ??
936 (await input({ message: "Board name:", default: "General Discussion" }));
937
938 const description =
939 args.description ??
940 (await input({ message: "Board description (optional):" }));
941
942 const sortOrderRaw = args["sort-order"];
943 const sortOrder =
944 sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined;
945
946 try {
947 const result = await createBoard(db, agent, config.forumDid, {
948 name,
949 ...(description && { description }),
950 ...(args.slug && { slug: args.slug }),
951 ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }),
952 categoryUri,
953 categoryId,
954 categoryCid,
955 });
956
957 if (result.skipped) {
958 consola.warn(`Board "${result.existingName}" already exists: ${result.uri}`);
959 } else {
960 consola.success(`Created board "${name}"`);
961 consola.info(`URI: ${result.uri}`);
962 }
963 } catch (error) {
964 consola.error(
965 "Failed to create board:",
966 error instanceof Error ? error.message : String(error)
967 );
968 await forumAgent.shutdown();
969 await cleanup();
970 process.exit(1);
971 }
972
973 await forumAgent.shutdown();
974 await cleanup();
975 },
976});
977
978export const boardCommand = defineCommand({
979 meta: {
980 name: "board",
981 description: "Manage forum boards",
982 },
983 subCommands: {
984 add: boardAddCommand,
985 },
986});
987```
988
989### Step 2: Register `boardCommand` in `index.ts`
990
991Add to `packages/cli/src/index.ts`:
992
993```typescript
994// Add import (after categoryCommand import):
995import { boardCommand } from "./commands/board.js";
996
997// Update subCommands:
998subCommands: {
999 init: initCommand,
1000 category: categoryCommand,
1001 board: boardCommand, // ← add this line
1002},
1003```
1004
1005### Step 3: Build to verify no TypeScript errors
1006
1007```sh
1008export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
1009pnpm --filter @atbb/cli lint
1010```
1011
1012Expected: No errors
1013
1014### Step 4: Commit
1015
1016```sh
1017git add packages/cli/src/commands/board.ts packages/cli/src/index.ts
1018git commit -m "feat: add atbb board add command (ATB-28)"
1019```
1020
1021---
1022
1023## Task 5: Extend `init` with Step 4 — seed initial structure
1024
1025**Files:**
1026- Modify: `packages/cli/src/commands/init.ts`
1027
1028### Step 1: Add imports for new step functions and confirm prompt
1029
1030At the top of `packages/cli/src/commands/init.ts`, add:
1031
1032```typescript
1033import { confirm } from "@inquirer/prompts";
1034import { createCategory } from "../lib/steps/create-category.js";
1035import { createBoard } from "../lib/steps/create-board.js";
1036```
1037
1038### Step 2: Add Step 4 to the run() function
1039
1040Locate the end of Step 3 (the `assignOwnerRole` try-catch block that ends around line 176), then add before the cleanup/success box block:
1041
1042```typescript
1043// Step 6: Seed initial categories and boards (optional)
1044consola.log("");
1045consola.info("Step 4: Seed Initial Structure");
1046
1047const shouldSeed = await confirm({
1048 message: "Seed an initial category and board?",
1049 default: true,
1050});
1051
1052if (shouldSeed) {
1053 const categoryName = await input({
1054 message: "Category name:",
1055 default: "General",
1056 });
1057
1058 const categoryDescription = await input({
1059 message: "Category description (optional):",
1060 });
1061
1062 let categoryUri: string | undefined;
1063 let categoryId: bigint | undefined;
1064 let categoryCid: string | undefined;
1065
1066 try {
1067 const categoryResult = await createCategory(db, agent, config.forumDid, {
1068 name: categoryName,
1069 ...(categoryDescription && { description: categoryDescription }),
1070 });
1071
1072 if (categoryResult.skipped) {
1073 consola.warn(`Category "${categoryResult.existingName}" already exists`);
1074 } else {
1075 consola.success(`Created category "${categoryName}": ${categoryResult.uri}`);
1076 }
1077
1078 categoryUri = categoryResult.uri;
1079 categoryCid = categoryResult.cid;
1080
1081 // Look up the categoryId from DB (needed for board FK)
1082 const { categories } = await import("@atbb/db");
1083 const { eq, and } = await import("drizzle-orm");
1084 const parts = categoryUri!.split("/");
1085 const rkey = parts[parts.length - 1];
1086 const [cat] = await db
1087 .select()
1088 .from(categories)
1089 .where(and(eq(categories.did, config.forumDid), eq(categories.rkey, rkey)))
1090 .limit(1);
1091 categoryId = cat?.id;
1092 } catch (error) {
1093 consola.error("Failed to create category:", error instanceof Error ? error.message : String(error));
1094 await forumAgent.shutdown();
1095 await cleanup();
1096 process.exit(1);
1097 }
1098
1099 if (categoryUri && categoryId && categoryCid) {
1100 const boardName = await input({
1101 message: "Board name:",
1102 default: "General Discussion",
1103 });
1104
1105 const boardDescription = await input({
1106 message: "Board description (optional):",
1107 });
1108
1109 try {
1110 const boardResult = await createBoard(db, agent, config.forumDid, {
1111 name: boardName,
1112 ...(boardDescription && { description: boardDescription }),
1113 categoryUri,
1114 categoryId,
1115 categoryCid,
1116 });
1117
1118 if (boardResult.skipped) {
1119 consola.warn(`Board "${boardResult.existingName}" already exists`);
1120 } else {
1121 consola.success(`Created board "${boardName}": ${boardResult.uri}`);
1122 }
1123 } catch (error) {
1124 consola.error("Failed to create board:", error instanceof Error ? error.message : String(error));
1125 await forumAgent.shutdown();
1126 await cleanup();
1127 process.exit(1);
1128 }
1129 }
1130} else {
1131 consola.info("Skipped. Add categories later with: atbb category add");
1132}
1133```
1134
1135**Note on the dynamic imports above:** The cleaner approach is to move the `categories` and `drizzle-orm` imports to the top of the file alongside the existing imports. Specifically add to the top of `init.ts`:
1136
1137```typescript
1138import { categories } from "@atbb/db";
1139import { eq, and } from "drizzle-orm";
1140import { confirm } from "@inquirer/prompts";
1141import { createCategory } from "../lib/steps/create-category.js";
1142import { createBoard } from "../lib/steps/create-board.js";
1143```
1144
1145And replace the dynamic import block in Step 4 with direct references to the top-level imports.
1146
1147### Step 3: Update the success message
1148
1149Find the `consola.box` success message at the end of `init.ts`. Update the "Next steps" message to remove the "Create categories and boards" note (they've been created):
1150
1151Replace the `message` array with:
1152
1153```typescript
1154message: [
1155 "Next steps:",
1156 " 1. Start the appview: pnpm --filter @atbb/appview dev",
1157 " 2. Start the web UI: pnpm --filter @atbb/web dev",
1158 ` 3. Log in as ${ownerInput} to access admin features`,
1159 " 4. Add more boards: atbb board add",
1160 " 5. Add more categories: atbb category add",
1161].join("\n"),
1162```
1163
1164### Step 4: Build to verify no TypeScript errors
1165
1166```sh
1167export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
1168pnpm --filter @atbb/cli lint
1169```
1170
1171Expected: No errors
1172
1173### Step 5: Run all CLI tests
1174
1175```sh
1176export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
1177pnpm --filter @atbb/cli exec vitest run
1178```
1179
1180Expected: All tests PASS (existing tests unaffected, new tests pass)
1181
1182### Step 6: Commit
1183
1184```sh
1185git add packages/cli/src/commands/init.ts
1186git commit -m "feat: extend init with optional category/board seeding step (ATB-28)"
1187```
1188
1189---
1190
1191## Task 6: Final verification
1192
1193### Step 1: Run all tests across the monorepo
1194
1195```sh
1196export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
1197pnpm test
1198```
1199
1200Expected: All tests PASS
1201
1202### Step 2: Typecheck all packages
1203
1204```sh
1205export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
1206pnpm turbo lint
1207```
1208
1209Expected: No TypeScript errors
1210
1211### Step 3: Build
1212
1213```sh
1214export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
1215pnpm build
1216```
1217
1218Expected: Build succeeds
1219
1220### Step 4: Smoke test the CLI (optional, requires running database)
1221
1222```sh
1223export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
1224# Verify new commands appear in help
1225pnpm --filter @atbb/cli dev -- category --help
1226pnpm --filter @atbb/cli dev -- board --help
1227pnpm --filter @atbb/cli dev -- category add --help
1228pnpm --filter @atbb/cli dev -- board add --help
1229```
1230
1231Expected: Help text showing flags for each command
1232
1233---
1234
1235## Summary of New Files
1236
1237| File | Purpose |
1238|------|---------|
1239| `packages/cli/src/lib/steps/create-category.ts` | Step module — idempotent PDS + DB write for categories |
1240| `packages/cli/src/lib/steps/create-board.ts` | Step module — idempotent PDS + DB write for boards |
1241| `packages/cli/src/commands/category.ts` | `atbb category add` command |
1242| `packages/cli/src/commands/board.ts` | `atbb board add` command with interactive category selection |
1243| `packages/cli/src/__tests__/create-category.test.ts` | Tests for createCategory step |
1244| `packages/cli/src/__tests__/create-board.test.ts` | Tests for createBoard step |
1245
1246## Modified Files
1247
1248| File | Change |
1249|------|--------|
1250| `packages/cli/src/index.ts` | Register `category` and `board` subcommands |
1251| `packages/cli/src/commands/init.ts` | Add Step 4: optional category/board seeding |