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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 |