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

feat: add atbb category add and board add commands with init seeding (#43)

* docs: CLI categories and boards design

* docs: CLI categories and boards implementation plan

* feat: add createCategory step module (ATB-28)

Implements TDD createCategory step with idempotent PDS write and DB insert,
slug derivation from name, and skipping when a category with the same name exists.

* feat: add createBoard step module (ATB-28)

* feat: add atbb category add command (ATB-28)

* feat: add atbb board add command (ATB-28)

Implements the `atbb board add` subcommand with interactive category
selection when --category-uri is not provided, validating the chosen
category against the database before creating the board record.

* fix: add try/catch around category resolution and URI validation in board add command (ATB-28)

- Wrap entire category resolution block (DB queries + interactive select prompt)
in try/catch so connection drops after the SELECT 1 probe properly call
forumAgent.shutdown() and cleanup() before process.exit(1)
- Validate AT URI format before parsing: reject URIs that don't start with
at:// or have fewer than 5 path segments, with an actionable error message

* feat: extend init with optional category/board seeding step (ATB-28)

* fix: remove dead catch blocks in step modules and add categoryId guard in init (ATB-28)

- create-category.ts / create-board.ts: both branches of the try/catch
re-threw unconditionally, making the catch a no-op; replaced with a
direct await assignment and removed the unused isProgrammingError import
- init.ts: added an explicit error-and-exit guard after the categoryId DB
lookup so a missing row causes a loud failure instead of silently
skipping board creation

* fix: address ATB-28 code review feedback

- Extract deriveSlug to packages/cli/src/lib/slug.ts (Issue 9 — dedup)
- Add isProgrammingError to packages/cli/src/lib/errors.ts and re-throw
in all three command handler catch blocks: category.ts, board.ts,
init.ts Step 4 (Issues 7/1)
- Wrap forumAgent.initialize() in try/catch in category.ts and board.ts
so PDS-unreachable errors call cleanup() before exit (Issue 6)
- Validate AT URI collection segment in board.ts: parts[3] must be
space.atbb.forum.category (Issue 4)
- Add forumDid and resource name context to all catch block error logs
using JSON.stringify (Issue 10)
- Fix misleading comment "Step 6 (label 4)" → "Step 4" in init.ts
- Remove dead guard (categoryUri && categoryId && categoryCid) in init.ts
Step 4 — guaranteed non-null by the !categoryId exit above (Suggestion)
- Add DB insert failure tests to create-category.test.ts and
create-board.test.ts (Issue 2)
- Add sortOrder include/omit tests to both step module test files (Suggestion)
- Add category-command.test.ts: command integration tests for category add
including prompt path, PDS init failure, error/programming-error handling
(Issue 3)
- Add board-command.test.ts: command integration tests for board add
including collection-segment validation, DB category lookup, error
handling (Issues 3/4)
- Add init-step4.test.ts: tests for Step 4 seeding — skip path, full
create path, !categoryId guard, createBoard failure, programming error
re-throw (Issue 5)

* fix: address second round of review feedback on ATB-28

- create-category.ts: warn when forumId is null (silent failure → visible warning)
- category.ts, board.ts: best-effort forumAgent.shutdown() in initialize() failure path
- init.ts: split combined try block so DB re-query failure doesn't report as
"Failed to create category" (the PDS write already succeeded by that point)
- Tests: add isAuthenticated()=false branch for category and board commands
- Tests: add interactive select and empty-categories paths for board command
- Tests: add createBoard programming error re-throw and DB re-query throw for init Step 4

authored by

Malpercio and committed by
GitHub
94eb069a 23a375fd

+3295 -2
+141
docs/plans/2026-02-18-cli-categories-boards-design.md
··· 1 + # CLI: Categories and Boards Commands — Design 2 + 3 + **Date:** 2026-02-18 4 + **Status:** Approved 5 + **Context:** Extends the existing `@atbb/cli` bootstrap tool (ATB-28) to support creating categories and boards — both as part of the `init` wizard and as standalone management commands. 6 + 7 + --- 8 + 9 + ## Problem 10 + 11 + The current `atbb init` wizard bootstraps the forum record, roles, and owner, but defers category/board creation to "the admin panel" (which doesn't exist yet). Forum operators need a way to set up the initial content structure from the CLI. 12 + 13 + --- 14 + 15 + ## Design 16 + 17 + ### New Commands 18 + 19 + ``` 20 + atbb category add # Create a new category on the Forum DID's PDS and DB 21 + atbb board add # Create a new board within a category 22 + ``` 23 + 24 + Both follow the same pattern as existing CLI steps: write to PDS first (using Forum DID credentials), then insert into the database. Idempotent: skip if a record with the same name already exists. 25 + 26 + ### Extended `init` — Step 4: Seed Initial Structure 27 + 28 + After the existing Step 3 (assign owner), `init` prompts: 29 + 30 + ``` 31 + Seed initial categories and boards? (Y/n) 32 + ``` 33 + 34 + If yes (the default), it prompts for: 35 + - Category name (default: "General") 36 + - Category description (optional) 37 + - Board name (default: "General Discussion") 38 + - Board description (optional) 39 + 40 + Uses the new `createCategory` / `createBoard` step functions. Skips if records already exist. 41 + 42 + --- 43 + 44 + ## File Structure 45 + 46 + ``` 47 + packages/cli/src/ 48 + lib/steps/ 49 + create-category.ts ← new 50 + create-board.ts ← new 51 + commands/ 52 + category.ts ← new citty subcommand group 53 + board.ts ← new citty subcommand group 54 + index.ts ← updated: register category + board 55 + commands/init.ts ← updated: Step 4 = seed initial structure 56 + ``` 57 + 58 + --- 59 + 60 + ## `atbb category add` 61 + 62 + **Flags** (all optional — prompts if absent): 63 + - `--name` — display name (max 100 graphemes per lexicon) 64 + - `--description` — optional description 65 + - `--slug` — URL-friendly identifier; auto-derived from name if omitted 66 + - `--sort-order` — numeric sort position (integer ≥ 0) 67 + 68 + **Flow:** 69 + 1. Preflight checks + DB connection + PDS auth 70 + 2. Write `space.atbb.forum.category` record to Forum DID's PDS (TID key) 71 + 3. Insert into `categories` table with `forumId` reference (looked up from DB) 72 + 4. Print created URI 73 + 74 + **Slug auto-derivation:** 75 + ``` 76 + name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') 77 + ``` 78 + 79 + --- 80 + 81 + ## `atbb board add` 82 + 83 + **Flags** (all optional — prompts if absent): 84 + - `--category-uri` — AT URI of parent category (`at://did/space.atbb.forum.category/rkey`) 85 + - `--name` — display name 86 + - `--description` — optional 87 + - `--slug` — auto-derived if omitted 88 + - `--sort-order` — optional 89 + 90 + If `--category-uri` is not provided, fetches all categories from DB and presents an interactive `select` list via `@inquirer/prompts`. 91 + 92 + **Flow:** 93 + 1. Preflight checks + DB connection + PDS auth 94 + 2. Resolve category: look up `categoryUri` in DB to get `categoryId`; return error if not found 95 + 3. Write `space.atbb.forum.board` record to PDS with `category` ref (strongRef: `{ uri, cid }`) 96 + 4. Insert into `boards` table with `categoryId` + `categoryUri` 97 + 5. Print created URI 98 + 99 + --- 100 + 101 + ## Step Modules 102 + 103 + ### `createCategory(db, agent, forumDid, input)` 104 + 105 + Input: `{ name, description?, slug?, sortOrder? }` 106 + Returns: `{ created, skipped, uri?, cid? }` 107 + 108 + Idempotent: checks `categories` table for existing row with `(did, name)` before writing. 109 + 110 + ### `createBoard(db, agent, forumDid, input)` 111 + 112 + Input: `{ name, description?, slug?, sortOrder?, categoryUri, categoryId, categoryCid }` 113 + Returns: `{ created, skipped, uri?, cid? }` 114 + 115 + Idempotent: checks `boards` table for existing row with `(did, name)` before writing. 116 + 117 + --- 118 + 119 + ## Error Handling 120 + 121 + Follows existing patterns: 122 + - `isProgrammingError(error)` — re-throw TypeErrors/ReferenceErrors 123 + - Network errors (PDS unreachable) — exit with friendly message 124 + - Not found (category missing from DB when adding board) — exit with actionable message 125 + - All errors in standalone commands: log + `process.exit(1)` (same as init) 126 + 127 + --- 128 + 129 + ## Testing 130 + 131 + - `src/__tests__/create-category.test.ts` — create/skip/PDS error/DB error 132 + - `src/__tests__/create-board.test.ts` — create/skip/missing category/PDS error/DB error 133 + - Commands (`category.ts`, `board.ts`) tested via CLI integration tests following init test patterns 134 + 135 + --- 136 + 137 + ## Not In Scope 138 + 139 + - `category list`, `board list` — can be added in a follow-up 140 + - `category edit`, `board edit` — post-MVP 141 + - Deleting categories/boards — post-MVP
+1251
docs/plans/2026-02-18-cli-categories-boards-implementation.md
··· 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 + 21 + Create `packages/cli/src/__tests__/create-category.test.ts`: 22 + 23 + ```typescript 24 + import { describe, it, expect, vi } from "vitest"; 25 + import { createCategory } from "../lib/steps/create-category.js"; 26 + 27 + describe("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 163 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 164 + pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts 165 + ``` 166 + 167 + Expected: FAIL with "Cannot find module '../lib/steps/create-category.js'" 168 + 169 + ### Step 3: Implement `create-category.ts` 170 + 171 + Create `packages/cli/src/lib/steps/create-category.ts`: 172 + 173 + ```typescript 174 + import type { AtpAgent } from "@atproto/api"; 175 + import type { Database } from "@atbb/db"; 176 + import { categories, forums } from "@atbb/db"; 177 + import { eq, and } from "drizzle-orm"; 178 + import { isProgrammingError } from "@atbb/atproto"; 179 + 180 + interface CreateCategoryInput { 181 + name: string; 182 + description?: string; 183 + slug?: string; 184 + sortOrder?: number; 185 + } 186 + 187 + interface CreateCategoryResult { 188 + created: boolean; 189 + skipped: boolean; 190 + uri?: string; 191 + cid?: string; 192 + existingName?: string; 193 + } 194 + 195 + function 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 + */ 207 + export 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 286 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 287 + pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts 288 + ``` 289 + 290 + Expected: All 5 tests PASS 291 + 292 + ### Step 5: Commit 293 + 294 + ```sh 295 + git add packages/cli/src/__tests__/create-category.test.ts packages/cli/src/lib/steps/create-category.ts 296 + git 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 + 309 + Create `packages/cli/src/__tests__/create-board.test.ts`: 310 + 311 + ```typescript 312 + import { describe, it, expect, vi } from "vitest"; 313 + import { createBoard } from "../lib/steps/create-board.js"; 314 + 315 + describe("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 442 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 443 + pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts 444 + ``` 445 + 446 + Expected: FAIL with "Cannot find module '../lib/steps/create-board.js'" 447 + 448 + ### Step 3: Implement `create-board.ts` 449 + 450 + Create `packages/cli/src/lib/steps/create-board.ts`: 451 + 452 + ```typescript 453 + import type { AtpAgent } from "@atproto/api"; 454 + import type { Database } from "@atbb/db"; 455 + import { boards } from "@atbb/db"; 456 + import { eq, and } from "drizzle-orm"; 457 + import { isProgrammingError } from "@atbb/atproto"; 458 + 459 + interface 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 + 469 + interface CreateBoardResult { 470 + created: boolean; 471 + skipped: boolean; 472 + uri?: string; 473 + cid?: string; 474 + existingName?: string; 475 + } 476 + 477 + function 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 + */ 489 + export 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 575 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 576 + pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts 577 + ``` 578 + 579 + Expected: All 4 tests PASS 580 + 581 + ### Step 5: Commit 582 + 583 + ```sh 584 + git add packages/cli/src/__tests__/create-board.test.ts packages/cli/src/lib/steps/create-board.ts 585 + git 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 + 597 + Create `packages/cli/src/commands/category.ts`: 598 + 599 + ```typescript 600 + import { defineCommand } from "citty"; 601 + import consola from "consola"; 602 + import { input } from "@inquirer/prompts"; 603 + import postgres from "postgres"; 604 + import { drizzle } from "drizzle-orm/postgres-js"; 605 + import * as schema from "@atbb/db"; 606 + import { ForumAgent } from "@atbb/atproto"; 607 + import { loadCliConfig } from "../lib/config.js"; 608 + import { checkEnvironment } from "../lib/preflight.js"; 609 + import { createCategory } from "../lib/steps/create-category.js"; 610 + 611 + const 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 + 728 + export 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 + 741 + Open `packages/cli/src/index.ts` and add the import + subcommand entry: 742 + 743 + ```typescript 744 + // Add this import (after existing imports): 745 + import { categoryCommand } from "./commands/category.js"; 746 + 747 + // Update subCommands: 748 + subCommands: { 749 + init: initCommand, 750 + category: categoryCommand, // ← add this line 751 + }, 752 + ``` 753 + 754 + ### Step 3: Build to verify no TypeScript errors 755 + 756 + ```sh 757 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 758 + pnpm --filter @atbb/cli lint 759 + ``` 760 + 761 + Expected: No errors 762 + 763 + ### Step 4: Commit 764 + 765 + ```sh 766 + git add packages/cli/src/commands/category.ts packages/cli/src/index.ts 767 + git 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 + 780 + Create `packages/cli/src/commands/board.ts`: 781 + 782 + ```typescript 783 + import { defineCommand } from "citty"; 784 + import consola from "consola"; 785 + import { input, select } from "@inquirer/prompts"; 786 + import postgres from "postgres"; 787 + import { drizzle } from "drizzle-orm/postgres-js"; 788 + import * as schema from "@atbb/db"; 789 + import { categories } from "@atbb/db"; 790 + import { eq, and } from "drizzle-orm"; 791 + import { ForumAgent } from "@atbb/atproto"; 792 + import { loadCliConfig } from "../lib/config.js"; 793 + import { checkEnvironment } from "../lib/preflight.js"; 794 + import { createBoard } from "../lib/steps/create-board.js"; 795 + 796 + const 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 + 978 + export 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 + 991 + Add to `packages/cli/src/index.ts`: 992 + 993 + ```typescript 994 + // Add import (after categoryCommand import): 995 + import { boardCommand } from "./commands/board.js"; 996 + 997 + // Update subCommands: 998 + subCommands: { 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 1008 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 1009 + pnpm --filter @atbb/cli lint 1010 + ``` 1011 + 1012 + Expected: No errors 1013 + 1014 + ### Step 4: Commit 1015 + 1016 + ```sh 1017 + git add packages/cli/src/commands/board.ts packages/cli/src/index.ts 1018 + git 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 + 1030 + At the top of `packages/cli/src/commands/init.ts`, add: 1031 + 1032 + ```typescript 1033 + import { confirm } from "@inquirer/prompts"; 1034 + import { createCategory } from "../lib/steps/create-category.js"; 1035 + import { createBoard } from "../lib/steps/create-board.js"; 1036 + ``` 1037 + 1038 + ### Step 2: Add Step 4 to the run() function 1039 + 1040 + Locate 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) 1044 + consola.log(""); 1045 + consola.info("Step 4: Seed Initial Structure"); 1046 + 1047 + const shouldSeed = await confirm({ 1048 + message: "Seed an initial category and board?", 1049 + default: true, 1050 + }); 1051 + 1052 + if (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 1138 + import { categories } from "@atbb/db"; 1139 + import { eq, and } from "drizzle-orm"; 1140 + import { confirm } from "@inquirer/prompts"; 1141 + import { createCategory } from "../lib/steps/create-category.js"; 1142 + import { createBoard } from "../lib/steps/create-board.js"; 1143 + ``` 1144 + 1145 + And 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 + 1149 + Find 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 + 1151 + Replace the `message` array with: 1152 + 1153 + ```typescript 1154 + message: [ 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 1167 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 1168 + pnpm --filter @atbb/cli lint 1169 + ``` 1170 + 1171 + Expected: No errors 1172 + 1173 + ### Step 5: Run all CLI tests 1174 + 1175 + ```sh 1176 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 1177 + pnpm --filter @atbb/cli exec vitest run 1178 + ``` 1179 + 1180 + Expected: All tests PASS (existing tests unaffected, new tests pass) 1181 + 1182 + ### Step 6: Commit 1183 + 1184 + ```sh 1185 + git add packages/cli/src/commands/init.ts 1186 + git 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 1196 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 1197 + pnpm test 1198 + ``` 1199 + 1200 + Expected: All tests PASS 1201 + 1202 + ### Step 2: Typecheck all packages 1203 + 1204 + ```sh 1205 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 1206 + pnpm turbo lint 1207 + ``` 1208 + 1209 + Expected: No TypeScript errors 1210 + 1211 + ### Step 3: Build 1212 + 1213 + ```sh 1214 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 1215 + pnpm build 1216 + ``` 1217 + 1218 + Expected: Build succeeds 1219 + 1220 + ### Step 4: Smoke test the CLI (optional, requires running database) 1221 + 1222 + ```sh 1223 + export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH" 1224 + # Verify new commands appear in help 1225 + pnpm --filter @atbb/cli dev -- category --help 1226 + pnpm --filter @atbb/cli dev -- board --help 1227 + pnpm --filter @atbb/cli dev -- category add --help 1228 + pnpm --filter @atbb/cli dev -- board add --help 1229 + ``` 1230 + 1231 + Expected: 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 |
+263
packages/cli/src/__tests__/board-command.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + // --- Module mocks --- 4 + 5 + const mockSql = Object.assign(vi.fn().mockResolvedValue(undefined), { 6 + end: vi.fn().mockResolvedValue(undefined), 7 + }); 8 + vi.mock("postgres", () => ({ default: vi.fn(() => mockSql) })); 9 + vi.mock("drizzle-orm/postgres-js", () => ({ drizzle: vi.fn(() => mockDb) })); 10 + vi.mock("@atbb/db", () => ({ 11 + default: {}, 12 + categories: "categories_table", 13 + })); 14 + vi.mock("drizzle-orm", () => ({ 15 + eq: vi.fn(), 16 + and: vi.fn(), 17 + })); 18 + 19 + const mockCreateBoard = vi.fn(); 20 + vi.mock("../lib/steps/create-board.js", () => ({ 21 + createBoard: (...args: unknown[]) => mockCreateBoard(...args), 22 + })); 23 + 24 + const mockForumAgentInstance = { 25 + initialize: vi.fn().mockResolvedValue(undefined), 26 + isAuthenticated: vi.fn().mockReturnValue(true), 27 + getAgent: vi.fn().mockReturnValue({}), 28 + getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }), 29 + shutdown: vi.fn().mockResolvedValue(undefined), 30 + }; 31 + vi.mock("@atbb/atproto", () => ({ 32 + ForumAgent: vi.fn(() => mockForumAgentInstance), 33 + })); 34 + 35 + vi.mock("../lib/config.js", () => ({ 36 + loadCliConfig: vi.fn(() => ({ 37 + databaseUrl: "postgres://test", 38 + pdsUrl: "https://pds.test", 39 + forumDid: "did:plc:testforum", 40 + forumHandle: "forum.test", 41 + forumPassword: "secret", 42 + })), 43 + })); 44 + 45 + vi.mock("../lib/preflight.js", () => ({ 46 + checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })), 47 + })); 48 + 49 + const mockInput = vi.fn(); 50 + const mockSelect = vi.fn(); 51 + vi.mock("@inquirer/prompts", () => ({ 52 + input: (...args: unknown[]) => mockInput(...args), 53 + select: (...args: unknown[]) => mockSelect(...args), 54 + })); 55 + 56 + // Mock DB with a category lookup that succeeds by default 57 + const mockCategoryRow = { 58 + id: BigInt(42), 59 + did: "did:plc:testforum", 60 + rkey: "cattid", 61 + cid: "bafycategory", 62 + name: "General", 63 + }; 64 + 65 + const mockDb = { 66 + select: vi.fn().mockReturnValue({ 67 + from: vi.fn().mockReturnValue({ 68 + where: vi.fn().mockReturnValue({ 69 + limit: vi.fn().mockResolvedValue([mockCategoryRow]), 70 + }), 71 + }), 72 + }), 73 + } as any; 74 + 75 + import { boardCommand } from "../commands/board.js"; 76 + 77 + const VALID_CATEGORY_URI = `at://did:plc:testforum/space.atbb.forum.category/cattid`; 78 + 79 + describe("board add command", () => { 80 + let exitSpy: ReturnType<typeof vi.spyOn>; 81 + 82 + function getAddRun() { 83 + return ((boardCommand.subCommands as any).add as any).run as (ctx: { args: Record<string, unknown> }) => Promise<void>; 84 + } 85 + 86 + beforeEach(() => { 87 + vi.clearAllMocks(); 88 + exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { 89 + throw new Error(`process.exit:${code}`); 90 + }) as any; 91 + 92 + // Restore DB mock after clearAllMocks 93 + mockDb.select.mockReturnValue({ 94 + from: vi.fn().mockReturnValue({ 95 + where: vi.fn().mockReturnValue({ 96 + limit: vi.fn().mockResolvedValue([mockCategoryRow]), 97 + }), 98 + }), 99 + }); 100 + 101 + // Default: createBoard succeeds 102 + mockCreateBoard.mockResolvedValue({ 103 + created: true, 104 + skipped: false, 105 + uri: "at://did:plc:testforum/space.atbb.forum.board/tid456", 106 + cid: "bafyboard", 107 + }); 108 + 109 + // Default: prompts return values 110 + mockInput.mockResolvedValue(""); 111 + }); 112 + 113 + afterEach(() => { 114 + exitSpy.mockRestore(); 115 + }); 116 + 117 + it("creates a board when category-uri and name are provided as args", async () => { 118 + const run = getAddRun(); 119 + await run({ args: { "category-uri": VALID_CATEGORY_URI, name: "General Discussion" } }); 120 + 121 + expect(mockCreateBoard).toHaveBeenCalledWith( 122 + mockDb, 123 + expect.anything(), 124 + "did:plc:testforum", 125 + expect.objectContaining({ 126 + name: "General Discussion", 127 + categoryUri: VALID_CATEGORY_URI, 128 + categoryId: BigInt(42), 129 + categoryCid: "bafycategory", 130 + }) 131 + ); 132 + }); 133 + 134 + it("exits with error when AT URI has wrong collection (not space.atbb.forum.category)", async () => { 135 + const wrongUri = "at://did:plc:testforum/space.atbb.forum.board/cattid"; 136 + const run = getAddRun(); 137 + await expect( 138 + run({ args: { "category-uri": wrongUri, name: "Test" } }) 139 + ).rejects.toThrow("process.exit:1"); 140 + expect(exitSpy).toHaveBeenCalledWith(1); 141 + // Board creation should never be called 142 + expect(mockCreateBoard).not.toHaveBeenCalled(); 143 + }); 144 + 145 + it("exits with error when AT URI format is invalid (missing parts)", async () => { 146 + const run = getAddRun(); 147 + await expect( 148 + run({ args: { "category-uri": "not-an-at-uri", name: "Test" } }) 149 + ).rejects.toThrow("process.exit:1"); 150 + expect(exitSpy).toHaveBeenCalledWith(1); 151 + expect(mockCreateBoard).not.toHaveBeenCalled(); 152 + }); 153 + 154 + it("exits with error when category URI is not found in DB", async () => { 155 + mockDb.select.mockReturnValueOnce({ 156 + from: vi.fn().mockReturnValue({ 157 + where: vi.fn().mockReturnValue({ 158 + limit: vi.fn().mockResolvedValue([]), // category not found 159 + }), 160 + }), 161 + }); 162 + 163 + const run = getAddRun(); 164 + await expect( 165 + run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) 166 + ).rejects.toThrow("process.exit:1"); 167 + expect(mockCreateBoard).not.toHaveBeenCalled(); 168 + }); 169 + 170 + it("exits when authentication succeeds but isAuthenticated returns false", async () => { 171 + mockForumAgentInstance.isAuthenticated.mockReturnValueOnce(false); 172 + mockForumAgentInstance.getStatus.mockReturnValueOnce({ status: "failed", error: "Invalid credentials" }); 173 + 174 + const run = getAddRun(); 175 + await expect( 176 + run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) 177 + ).rejects.toThrow("process.exit:1"); 178 + expect(exitSpy).toHaveBeenCalledWith(1); 179 + expect(mockCreateBoard).not.toHaveBeenCalled(); 180 + }); 181 + 182 + it("uses interactive select when no category-uri arg is provided", async () => { 183 + mockSelect.mockResolvedValueOnce(mockCategoryRow); 184 + 185 + const run = getAddRun(); 186 + await run({ args: { name: "Test Board" } }); 187 + 188 + expect(mockSelect).toHaveBeenCalled(); 189 + expect(mockCreateBoard).toHaveBeenCalledWith( 190 + mockDb, 191 + expect.anything(), 192 + "did:plc:testforum", 193 + expect.objectContaining({ 194 + categoryUri: `at://${mockCategoryRow.did}/space.atbb.forum.category/${mockCategoryRow.rkey}`, 195 + categoryId: mockCategoryRow.id, 196 + categoryCid: mockCategoryRow.cid, 197 + }) 198 + ); 199 + }); 200 + 201 + it("exits when no categories exist in DB and no category-uri provided", async () => { 202 + mockDb.select.mockReturnValueOnce({ 203 + from: vi.fn().mockReturnValue({ 204 + where: vi.fn().mockReturnValue({ 205 + limit: vi.fn().mockResolvedValue([]), // empty categories list 206 + }), 207 + }), 208 + }); 209 + 210 + const run = getAddRun(); 211 + await expect(run({ args: { name: "Test Board" } })).rejects.toThrow("process.exit:1"); 212 + expect(exitSpy).toHaveBeenCalledWith(1); 213 + expect(mockCreateBoard).not.toHaveBeenCalled(); 214 + }); 215 + 216 + it("exits when forumAgent.initialize throws (PDS unreachable)", async () => { 217 + mockForumAgentInstance.initialize.mockRejectedValueOnce( 218 + new Error("fetch failed") 219 + ); 220 + 221 + const run = getAddRun(); 222 + await expect( 223 + run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test" } }) 224 + ).rejects.toThrow("process.exit:1"); 225 + expect(exitSpy).toHaveBeenCalledWith(1); 226 + }); 227 + 228 + it("exits when createBoard fails (runtime error)", async () => { 229 + mockCreateBoard.mockRejectedValueOnce(new Error("PDS write failed")); 230 + 231 + const run = getAddRun(); 232 + await expect( 233 + run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test Board" } }) 234 + ).rejects.toThrow("process.exit:1"); 235 + expect(exitSpy).toHaveBeenCalledWith(1); 236 + }); 237 + 238 + it("re-throws programming errors from createBoard", async () => { 239 + mockCreateBoard.mockRejectedValueOnce( 240 + new TypeError("Cannot read properties of undefined") 241 + ); 242 + 243 + const run = getAddRun(); 244 + await expect( 245 + run({ args: { "category-uri": VALID_CATEGORY_URI, name: "Test Board" } }) 246 + ).rejects.toThrow(TypeError); 247 + expect(exitSpy).not.toHaveBeenCalled(); 248 + }); 249 + 250 + it("skips creating when board already exists in category", async () => { 251 + mockCreateBoard.mockResolvedValueOnce({ 252 + created: false, 253 + skipped: true, 254 + uri: "at://did:plc:testforum/space.atbb.forum.board/existing", 255 + existingName: "General Discussion", 256 + }); 257 + 258 + const run = getAddRun(); 259 + await run({ args: { "category-uri": VALID_CATEGORY_URI, name: "General Discussion" } }); 260 + 261 + expect(exitSpy).not.toHaveBeenCalled(); 262 + }); 263 + });
+168
packages/cli/src/__tests__/category-command.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + // --- Module mocks (must be declared before imports that reference them) --- 4 + 5 + const mockSql = Object.assign(vi.fn().mockResolvedValue(undefined), { 6 + end: vi.fn().mockResolvedValue(undefined), 7 + }); 8 + vi.mock("postgres", () => ({ default: vi.fn(() => mockSql) })); 9 + vi.mock("drizzle-orm/postgres-js", () => ({ drizzle: vi.fn(() => mockDb) })); 10 + vi.mock("@atbb/db", () => ({ default: {} })); 11 + 12 + const mockCreateCategory = vi.fn(); 13 + vi.mock("../lib/steps/create-category.js", () => ({ 14 + createCategory: (...args: unknown[]) => mockCreateCategory(...args), 15 + })); 16 + 17 + const mockForumAgentInstance = { 18 + initialize: vi.fn().mockResolvedValue(undefined), 19 + isAuthenticated: vi.fn().mockReturnValue(true), 20 + getAgent: vi.fn().mockReturnValue({}), 21 + getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }), 22 + shutdown: vi.fn().mockResolvedValue(undefined), 23 + }; 24 + vi.mock("@atbb/atproto", () => ({ 25 + ForumAgent: vi.fn(() => mockForumAgentInstance), 26 + })); 27 + 28 + vi.mock("../lib/config.js", () => ({ 29 + loadCliConfig: vi.fn(() => ({ 30 + databaseUrl: "postgres://test", 31 + pdsUrl: "https://pds.test", 32 + forumDid: "did:plc:testforum", 33 + forumHandle: "forum.test", 34 + forumPassword: "secret", 35 + })), 36 + })); 37 + 38 + vi.mock("../lib/preflight.js", () => ({ 39 + checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })), 40 + })); 41 + 42 + const mockInput = vi.fn(); 43 + vi.mock("@inquirer/prompts", () => ({ 44 + input: (...args: unknown[]) => mockInput(...args), 45 + select: vi.fn(), 46 + })); 47 + 48 + // --- Mock DB (shared, created before mock declarations above) --- 49 + const mockDb = {} as any; 50 + 51 + // --- Import the command under test (after all mocks are registered) --- 52 + import { categoryCommand } from "../commands/category.js"; 53 + 54 + describe("category add command", () => { 55 + let exitSpy: ReturnType<typeof vi.spyOn>; 56 + 57 + beforeEach(() => { 58 + vi.clearAllMocks(); 59 + // Turn process.exit into a catchable throw so tests don't die 60 + exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { 61 + throw new Error(`process.exit:${code}`); 62 + }) as any; 63 + // Default: createCategory succeeds with a new category 64 + mockCreateCategory.mockResolvedValue({ 65 + created: true, 66 + skipped: false, 67 + uri: "at://did:plc:testforum/space.atbb.forum.category/tid123", 68 + cid: "bafytest", 69 + }); 70 + }); 71 + 72 + afterEach(() => { 73 + exitSpy.mockRestore(); 74 + }); 75 + 76 + // Helper to get the run function from the add subcommand 77 + function getAddRun() { 78 + return ((categoryCommand.subCommands as any).add as any).run as (ctx: { args: Record<string, unknown> }) => Promise<void>; 79 + } 80 + 81 + it("creates a category when name is provided as arg", async () => { 82 + const run = getAddRun(); 83 + await run({ args: { name: "General" } }); 84 + 85 + expect(mockCreateCategory).toHaveBeenCalledWith( 86 + mockDb, 87 + expect.anything(), 88 + "did:plc:testforum", 89 + expect.objectContaining({ name: "General" }) 90 + ); 91 + }); 92 + 93 + it("prompts for name when not provided as arg", async () => { 94 + mockInput.mockResolvedValueOnce("Interactive Category"); 95 + mockInput.mockResolvedValueOnce(""); // description prompt 96 + 97 + const run = getAddRun(); 98 + await run({ args: {} }); 99 + 100 + expect(mockInput).toHaveBeenCalledWith( 101 + expect.objectContaining({ message: "Category name:" }) 102 + ); 103 + expect(mockCreateCategory).toHaveBeenCalledWith( 104 + mockDb, 105 + expect.anything(), 106 + "did:plc:testforum", 107 + expect.objectContaining({ name: "Interactive Category" }) 108 + ); 109 + }); 110 + 111 + it("skips creating when category already exists", async () => { 112 + mockCreateCategory.mockResolvedValueOnce({ 113 + created: false, 114 + skipped: true, 115 + uri: "at://did:plc:testforum/space.atbb.forum.category/old", 116 + existingName: "General", 117 + }); 118 + 119 + const run = getAddRun(); 120 + await run({ args: { name: "General" } }); 121 + 122 + // Should not exit with error 123 + expect(exitSpy).not.toHaveBeenCalled(); 124 + }); 125 + 126 + it("exits when authentication succeeds but isAuthenticated returns false", async () => { 127 + mockForumAgentInstance.isAuthenticated.mockReturnValueOnce(false); 128 + mockForumAgentInstance.getStatus.mockReturnValueOnce({ status: "failed", error: "Invalid credentials" }); 129 + 130 + const run = getAddRun(); 131 + await expect(run({ args: { name: "General" } })).rejects.toThrow("process.exit:1"); 132 + expect(exitSpy).toHaveBeenCalledWith(1); 133 + expect(mockCreateCategory).not.toHaveBeenCalled(); 134 + }); 135 + 136 + it("exits when forumAgent.initialize throws (PDS unreachable)", async () => { 137 + mockForumAgentInstance.initialize.mockRejectedValueOnce( 138 + new Error("fetch failed") 139 + ); 140 + 141 + const run = getAddRun(); 142 + await expect(run({ args: { name: "General" } })).rejects.toThrow( 143 + "process.exit:1" 144 + ); 145 + expect(exitSpy).toHaveBeenCalledWith(1); 146 + }); 147 + 148 + it("exits when createCategory fails (runtime error)", async () => { 149 + mockCreateCategory.mockRejectedValueOnce(new Error("PDS write failed")); 150 + 151 + const run = getAddRun(); 152 + await expect(run({ args: { name: "General" } })).rejects.toThrow( 153 + "process.exit:1" 154 + ); 155 + expect(exitSpy).toHaveBeenCalledWith(1); 156 + }); 157 + 158 + it("re-throws programming errors from createCategory", async () => { 159 + mockCreateCategory.mockRejectedValueOnce( 160 + new TypeError("Cannot read properties of undefined") 161 + ); 162 + 163 + const run = getAddRun(); 164 + await expect(run({ args: { name: "General" } })).rejects.toThrow(TypeError); 165 + // process.exit should NOT have been called for a programming error 166 + expect(exitSpy).not.toHaveBeenCalled(); 167 + }); 168 + });
+176
packages/cli/src/__tests__/create-board.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { createBoard } from "../lib/steps/create-board.js"; 3 + 4 + describe("createBoard", () => { 5 + const forumDid = "did:plc:testforum"; 6 + const categoryUri = `at://${forumDid}/space.atbb.forum.category/cattid`; 7 + const categoryId = BigInt(42); 8 + const categoryCid = "bafycategory"; 9 + 10 + function mockDb(options: { existingBoard?: any } = {}) { 11 + return { 12 + select: vi.fn().mockReturnValue({ 13 + from: vi.fn().mockReturnValue({ 14 + where: vi.fn().mockReturnValue({ 15 + limit: vi.fn().mockResolvedValue( 16 + options.existingBoard ? [options.existingBoard] : [] 17 + ), 18 + }), 19 + }), 20 + }), 21 + insert: vi.fn().mockReturnValue({ 22 + values: vi.fn().mockResolvedValue(undefined), 23 + }), 24 + } as any; 25 + } 26 + 27 + function mockAgent(overrides: Record<string, any> = {}) { 28 + return { 29 + com: { 30 + atproto: { 31 + repo: { 32 + createRecord: vi.fn().mockResolvedValue({ 33 + data: { 34 + uri: `at://${forumDid}/space.atbb.forum.board/tid456`, 35 + cid: "bafyboard", 36 + }, 37 + }), 38 + ...overrides, 39 + }, 40 + }, 41 + }, 42 + } as any; 43 + } 44 + 45 + const baseInput = { 46 + name: "General Discussion", 47 + categoryUri, 48 + categoryId, 49 + categoryCid, 50 + }; 51 + 52 + it("creates board on PDS and inserts into DB", async () => { 53 + const db = mockDb(); 54 + const agent = mockAgent(); 55 + 56 + const result = await createBoard(db, agent, forumDid, baseInput); 57 + 58 + expect(result.created).toBe(true); 59 + expect(result.skipped).toBe(false); 60 + expect(result.uri).toContain("space.atbb.forum.board/tid456"); 61 + expect(result.cid).toBe("bafyboard"); 62 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 63 + expect.objectContaining({ 64 + repo: forumDid, 65 + collection: "space.atbb.forum.board", 66 + record: expect.objectContaining({ 67 + $type: "space.atbb.forum.board", 68 + name: "General Discussion", 69 + // Board record includes the category ref nested under "category" 70 + category: { 71 + category: { uri: categoryUri, cid: categoryCid }, 72 + }, 73 + }), 74 + }) 75 + ); 76 + expect(db.insert).toHaveBeenCalled(); 77 + }); 78 + 79 + it("derives slug from name", async () => { 80 + const db = mockDb(); 81 + const agent = mockAgent(); 82 + 83 + await createBoard(db, agent, forumDid, { 84 + ...baseInput, 85 + name: "Off Topic Chat", 86 + }); 87 + 88 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 89 + expect.objectContaining({ 90 + record: expect.objectContaining({ slug: "off-topic-chat" }), 91 + }) 92 + ); 93 + }); 94 + 95 + it("skips when board with same name exists in the same category", async () => { 96 + const db = mockDb({ 97 + existingBoard: { 98 + did: forumDid, 99 + rkey: "existingtid", 100 + cid: "bafyexisting", 101 + name: "General Discussion", 102 + }, 103 + }); 104 + const agent = mockAgent(); 105 + 106 + const result = await createBoard(db, agent, forumDid, baseInput); 107 + 108 + expect(result.created).toBe(false); 109 + expect(result.skipped).toBe(true); 110 + expect(result.existingName).toBe("General Discussion"); 111 + expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); 112 + expect(db.insert).not.toHaveBeenCalled(); 113 + }); 114 + 115 + it("throws when PDS write fails", async () => { 116 + const db = mockDb(); 117 + const agent = mockAgent({ 118 + createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")), 119 + }); 120 + 121 + await expect(createBoard(db, agent, forumDid, baseInput)).rejects.toThrow( 122 + "PDS write failed" 123 + ); 124 + }); 125 + 126 + it("throws when DB insert fails after successful PDS write", async () => { 127 + const db = { 128 + select: vi.fn().mockReturnValue({ 129 + from: vi.fn().mockReturnValue({ 130 + where: vi.fn().mockReturnValue({ 131 + limit: vi.fn().mockResolvedValue([]), // no existing board 132 + }), 133 + }), 134 + }), 135 + insert: vi.fn().mockReturnValue({ 136 + values: vi.fn().mockRejectedValue(new Error("DB insert failed")), 137 + }), 138 + } as any; 139 + const agent = mockAgent(); 140 + 141 + await expect(createBoard(db, agent, forumDid, baseInput)).rejects.toThrow( 142 + "DB insert failed" 143 + ); 144 + // PDS write happened before the failing DB insert 145 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalled(); 146 + }); 147 + 148 + it("includes sortOrder in PDS record and DB row when provided", async () => { 149 + const db = mockDb(); 150 + const agent = mockAgent(); 151 + 152 + await createBoard(db, agent, forumDid, { ...baseInput, sortOrder: 3 }); 153 + 154 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 155 + expect.objectContaining({ 156 + record: expect.objectContaining({ sortOrder: 3 }), 157 + }) 158 + ); 159 + expect(db.insert().values).toHaveBeenCalledWith( 160 + expect.objectContaining({ sortOrder: 3 }) 161 + ); 162 + }); 163 + 164 + it("omits sortOrder from PDS record and DB row when not provided", async () => { 165 + const db = mockDb(); 166 + const agent = mockAgent(); 167 + 168 + await createBoard(db, agent, forumDid, baseInput); 169 + 170 + const record = (agent.com.atproto.repo.createRecord as ReturnType<typeof vi.fn>).mock.calls[0][0].record; 171 + expect(record).not.toHaveProperty("sortOrder"); 172 + expect(db.insert().values).toHaveBeenCalledWith( 173 + expect.objectContaining({ sortOrder: null }) 174 + ); 175 + }); 176 + });
+190
packages/cli/src/__tests__/create-category.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { createCategory } from "../lib/steps/create-category.js"; 3 + 4 + describe("createCategory", () => { 5 + const forumDid = "did:plc:testforum"; 6 + 7 + // Builds a mock DB. If existingCategory is set, the first select() returns it. 8 + // The second select() (forum lookup) always returns a mock forum row. 9 + function mockDb(options: { existingCategory?: any } = {}) { 10 + let callCount = 0; 11 + return { 12 + select: vi.fn().mockImplementation(() => ({ 13 + from: vi.fn().mockReturnValue({ 14 + where: vi.fn().mockReturnValue({ 15 + limit: vi.fn().mockImplementation(() => { 16 + callCount++; 17 + if (callCount === 1) { 18 + // First select: category idempotency check 19 + return options.existingCategory ? [options.existingCategory] : []; 20 + } 21 + // Second select: forum lookup for forumId 22 + return [{ id: BigInt(1) }]; 23 + }), 24 + }), 25 + }), 26 + })), 27 + insert: vi.fn().mockReturnValue({ 28 + values: vi.fn().mockResolvedValue(undefined), 29 + }), 30 + } as any; 31 + } 32 + 33 + function mockAgent(overrides: Record<string, any> = {}) { 34 + return { 35 + com: { 36 + atproto: { 37 + repo: { 38 + createRecord: vi.fn().mockResolvedValue({ 39 + data: { 40 + uri: `at://${forumDid}/space.atbb.forum.category/tid123`, 41 + cid: "bafytest", 42 + }, 43 + }), 44 + ...overrides, 45 + }, 46 + }, 47 + }, 48 + } as any; 49 + } 50 + 51 + it("creates category on PDS and inserts into DB", async () => { 52 + const db = mockDb(); 53 + const agent = mockAgent(); 54 + 55 + const result = await createCategory(db, agent, forumDid, { 56 + name: "General", 57 + description: "General discussion", 58 + }); 59 + 60 + expect(result.created).toBe(true); 61 + expect(result.skipped).toBe(false); 62 + expect(result.uri).toContain("space.atbb.forum.category/tid123"); 63 + expect(result.cid).toBe("bafytest"); 64 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 65 + expect.objectContaining({ 66 + repo: forumDid, 67 + collection: "space.atbb.forum.category", 68 + record: expect.objectContaining({ 69 + $type: "space.atbb.forum.category", 70 + name: "General", 71 + description: "General discussion", 72 + }), 73 + }) 74 + ); 75 + expect(db.insert).toHaveBeenCalled(); 76 + }); 77 + 78 + it("derives slug from name when not provided", async () => { 79 + const db = mockDb(); 80 + const agent = mockAgent(); 81 + 82 + await createCategory(db, agent, forumDid, { name: "My Cool Category" }); 83 + 84 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 85 + expect.objectContaining({ 86 + record: expect.objectContaining({ slug: "my-cool-category" }), 87 + }) 88 + ); 89 + }); 90 + 91 + it("uses provided slug instead of deriving one", async () => { 92 + const db = mockDb(); 93 + const agent = mockAgent(); 94 + 95 + await createCategory(db, agent, forumDid, { name: "General", slug: "gen" }); 96 + 97 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 98 + expect.objectContaining({ 99 + record: expect.objectContaining({ slug: "gen" }), 100 + }) 101 + ); 102 + }); 103 + 104 + it("skips when category with same name already exists", async () => { 105 + const db = mockDb({ 106 + existingCategory: { 107 + did: forumDid, 108 + rkey: "existingtid", 109 + cid: "bafyexisting", 110 + name: "General", 111 + }, 112 + }); 113 + const agent = mockAgent(); 114 + 115 + const result = await createCategory(db, agent, forumDid, { name: "General" }); 116 + 117 + expect(result.created).toBe(false); 118 + expect(result.skipped).toBe(true); 119 + expect(result.existingName).toBe("General"); 120 + expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); 121 + expect(db.insert).not.toHaveBeenCalled(); 122 + }); 123 + 124 + it("throws when PDS write fails", async () => { 125 + const db = mockDb(); 126 + const agent = mockAgent({ 127 + createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")), 128 + }); 129 + 130 + await expect( 131 + createCategory(db, agent, forumDid, { name: "General" }) 132 + ).rejects.toThrow("PDS write failed"); 133 + }); 134 + 135 + it("throws when DB insert fails after successful PDS write", async () => { 136 + let callCount = 0; 137 + const db = { 138 + select: vi.fn().mockImplementation(() => ({ 139 + from: vi.fn().mockReturnValue({ 140 + where: vi.fn().mockReturnValue({ 141 + limit: vi.fn().mockImplementation(() => { 142 + callCount++; 143 + if (callCount === 1) return []; // no existing category 144 + return [{ id: BigInt(1) }]; // forum lookup 145 + }), 146 + }), 147 + }), 148 + })), 149 + insert: vi.fn().mockReturnValue({ 150 + values: vi.fn().mockRejectedValue(new Error("DB insert failed")), 151 + }), 152 + } as any; 153 + const agent = mockAgent(); 154 + 155 + await expect( 156 + createCategory(db, agent, forumDid, { name: "General" }) 157 + ).rejects.toThrow("DB insert failed"); 158 + // PDS write happened before the failing DB insert 159 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalled(); 160 + }); 161 + 162 + it("includes sortOrder in PDS record and DB row when provided", async () => { 163 + const db = mockDb(); 164 + const agent = mockAgent(); 165 + 166 + await createCategory(db, agent, forumDid, { name: "General", sortOrder: 5 }); 167 + 168 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 169 + expect.objectContaining({ 170 + record: expect.objectContaining({ sortOrder: 5 }), 171 + }) 172 + ); 173 + expect(db.insert().values).toHaveBeenCalledWith( 174 + expect.objectContaining({ sortOrder: 5 }) 175 + ); 176 + }); 177 + 178 + it("omits sortOrder from PDS record and DB row when not provided", async () => { 179 + const db = mockDb(); 180 + const agent = mockAgent(); 181 + 182 + await createCategory(db, agent, forumDid, { name: "General" }); 183 + 184 + const record = (agent.com.atproto.repo.createRecord as ReturnType<typeof vi.fn>).mock.calls[0][0].record; 185 + expect(record).not.toHaveProperty("sortOrder"); 186 + expect(db.insert().values).toHaveBeenCalledWith( 187 + expect.objectContaining({ sortOrder: null }) 188 + ); 189 + }); 190 + });
+320
packages/cli/src/__tests__/init-step4.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + // --- Module mocks --- 4 + 5 + const mockSql = Object.assign(vi.fn().mockResolvedValue(undefined), { 6 + end: vi.fn().mockResolvedValue(undefined), 7 + }); 8 + vi.mock("postgres", () => ({ default: vi.fn(() => mockSql) })); 9 + vi.mock("drizzle-orm/postgres-js", () => ({ drizzle: vi.fn(() => mockDb) })); 10 + vi.mock("@atbb/db", () => ({ 11 + default: {}, 12 + categories: "categories_table", 13 + })); 14 + vi.mock("drizzle-orm", () => ({ 15 + eq: vi.fn(), 16 + and: vi.fn(), 17 + })); 18 + 19 + const mockCreateForumRecord = vi.fn(); 20 + vi.mock("../lib/steps/create-forum.js", () => ({ 21 + createForumRecord: (...args: unknown[]) => mockCreateForumRecord(...args), 22 + })); 23 + 24 + const mockSeedDefaultRoles = vi.fn(); 25 + vi.mock("../lib/steps/seed-roles.js", () => ({ 26 + seedDefaultRoles: (...args: unknown[]) => mockSeedDefaultRoles(...args), 27 + })); 28 + 29 + const mockAssignOwnerRole = vi.fn(); 30 + vi.mock("../lib/steps/assign-owner.js", () => ({ 31 + assignOwnerRole: (...args: unknown[]) => mockAssignOwnerRole(...args), 32 + })); 33 + 34 + const mockCreateCategory = vi.fn(); 35 + vi.mock("../lib/steps/create-category.js", () => ({ 36 + createCategory: (...args: unknown[]) => mockCreateCategory(...args), 37 + })); 38 + 39 + const mockCreateBoard = vi.fn(); 40 + vi.mock("../lib/steps/create-board.js", () => ({ 41 + createBoard: (...args: unknown[]) => mockCreateBoard(...args), 42 + })); 43 + 44 + const mockForumAgentInstance = { 45 + initialize: vi.fn().mockResolvedValue(undefined), 46 + isAuthenticated: vi.fn().mockReturnValue(true), 47 + getAgent: vi.fn().mockReturnValue({}), 48 + getStatus: vi.fn().mockReturnValue({ status: "authenticated", error: undefined }), 49 + shutdown: vi.fn().mockResolvedValue(undefined), 50 + }; 51 + vi.mock("@atbb/atproto", () => ({ 52 + ForumAgent: vi.fn(() => mockForumAgentInstance), 53 + resolveIdentity: vi.fn().mockResolvedValue({ did: "did:plc:owner", handle: "owner.test" }), 54 + })); 55 + 56 + vi.mock("../lib/config.js", () => ({ 57 + loadCliConfig: vi.fn(() => ({ 58 + databaseUrl: "postgres://test", 59 + pdsUrl: "https://pds.test", 60 + forumDid: "did:plc:testforum", 61 + forumHandle: "forum.test", 62 + forumPassword: "secret", 63 + })), 64 + })); 65 + 66 + vi.mock("../lib/preflight.js", () => ({ 67 + checkEnvironment: vi.fn(() => ({ ok: true, errors: [] })), 68 + })); 69 + 70 + const mockInput = vi.fn(); 71 + const mockConfirm = vi.fn(); 72 + vi.mock("@inquirer/prompts", () => ({ 73 + input: (...args: unknown[]) => mockInput(...args), 74 + confirm: (...args: unknown[]) => mockConfirm(...args), 75 + })); 76 + 77 + // Mock DB: the category ID lookup in Step 4 is a select().from().where().limit() 78 + const mockCategoryIdRow = { id: BigInt(99) }; 79 + const mockDb = { 80 + select: vi.fn().mockReturnValue({ 81 + from: vi.fn().mockReturnValue({ 82 + where: vi.fn().mockReturnValue({ 83 + limit: vi.fn().mockResolvedValue([mockCategoryIdRow]), 84 + }), 85 + }), 86 + }), 87 + } as any; 88 + 89 + import { initCommand } from "../commands/init.js"; 90 + 91 + function getInitRun() { 92 + return (initCommand as any).run as (ctx: { args: Record<string, unknown> }) => Promise<void>; 93 + } 94 + 95 + // Helpers that set up the happy-path mocks for Steps 1-3. 96 + // All tests pass forum-name and owner as args, so only forum-description needs 97 + // a prompt in Steps 1-3. 98 + function setupSteps1to3() { 99 + mockCreateForumRecord.mockResolvedValue({ created: true, skipped: false, uri: "at://f/forum/self" }); 100 + const ownerRole = { name: "Owner", uri: "at://f/space.atbb.forum.role/tid1" }; 101 + mockSeedDefaultRoles.mockResolvedValue({ created: 3, skipped: 0, roles: [ownerRole] }); 102 + mockAssignOwnerRole.mockResolvedValue({ skipped: false }); 103 + // Only one prompt consumed in steps 1-3 (forum description; name+owner come from args) 104 + mockInput.mockResolvedValueOnce(""); // forum description 105 + } 106 + 107 + describe("init command — Step 4 (seed initial structure)", () => { 108 + let exitSpy: ReturnType<typeof vi.spyOn>; 109 + 110 + beforeEach(() => { 111 + vi.clearAllMocks(); 112 + exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { 113 + throw new Error(`process.exit:${code}`); 114 + }) as any; 115 + 116 + // Restore DB mock after clearAllMocks 117 + mockDb.select.mockReturnValue({ 118 + from: vi.fn().mockReturnValue({ 119 + where: vi.fn().mockReturnValue({ 120 + limit: vi.fn().mockResolvedValue([mockCategoryIdRow]), 121 + }), 122 + }), 123 + }); 124 + }); 125 + 126 + afterEach(() => { 127 + exitSpy.mockRestore(); 128 + }); 129 + 130 + it("skips category and board creation when user declines seeding", async () => { 131 + setupSteps1to3(); 132 + mockConfirm.mockResolvedValueOnce(false); // "Seed an initial category and board?" → No 133 + 134 + const run = getInitRun(); 135 + await run({ args: { "forum-name": "Test Forum", owner: "owner.test" } }); 136 + 137 + expect(mockCreateCategory).not.toHaveBeenCalled(); 138 + expect(mockCreateBoard).not.toHaveBeenCalled(); 139 + expect(exitSpy).not.toHaveBeenCalled(); 140 + }); 141 + 142 + it("creates category and board when user confirms seeding", async () => { 143 + setupSteps1to3(); 144 + mockConfirm.mockResolvedValueOnce(true); 145 + mockCreateCategory.mockResolvedValueOnce({ 146 + created: true, 147 + skipped: false, 148 + uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 149 + cid: "bafycat", 150 + }); 151 + mockCreateBoard.mockResolvedValueOnce({ 152 + created: true, 153 + skipped: false, 154 + uri: "at://did:plc:testforum/space.atbb.forum.board/boardtid", 155 + cid: "bafyboard", 156 + }); 157 + // Prompts for step 4 (appended after the forum-description prompt from setupSteps1to3) 158 + mockInput 159 + .mockResolvedValueOnce("General") // category name 160 + .mockResolvedValueOnce("") // category description 161 + .mockResolvedValueOnce("General Discussion") // board name 162 + .mockResolvedValueOnce(""); // board description 163 + 164 + const run = getInitRun(); 165 + await run({ args: { "forum-name": "Test Forum", owner: "owner.test" } }); 166 + 167 + expect(mockCreateCategory).toHaveBeenCalledWith( 168 + mockDb, 169 + expect.anything(), 170 + "did:plc:testforum", 171 + expect.objectContaining({ name: "General" }) 172 + ); 173 + expect(mockCreateBoard).toHaveBeenCalledWith( 174 + mockDb, 175 + expect.anything(), 176 + "did:plc:testforum", 177 + expect.objectContaining({ 178 + categoryUri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 179 + categoryId: BigInt(99), 180 + categoryCid: "bafycat", 181 + }) 182 + ); 183 + expect(exitSpy).not.toHaveBeenCalled(); 184 + }); 185 + 186 + it("exits if categoryId cannot be found in DB after category creation", async () => { 187 + setupSteps1to3(); 188 + mockConfirm.mockResolvedValueOnce(true); 189 + mockCreateCategory.mockResolvedValueOnce({ 190 + created: true, 191 + skipped: false, 192 + uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 193 + cid: "bafycat", 194 + }); 195 + // DB lookup returns empty (simulates race condition or failed insert) 196 + mockDb.select.mockReturnValueOnce({ 197 + from: vi.fn().mockReturnValue({ 198 + where: vi.fn().mockReturnValue({ 199 + limit: vi.fn().mockResolvedValue([]), // no category row found 200 + }), 201 + }), 202 + }); 203 + // Prompts for step 4 204 + mockInput 205 + .mockResolvedValueOnce("General") 206 + .mockResolvedValueOnce(""); 207 + 208 + const run = getInitRun(); 209 + await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 210 + "process.exit:1" 211 + ); 212 + // Board creation should NOT be called when categoryId is missing 213 + expect(mockCreateBoard).not.toHaveBeenCalled(); 214 + }); 215 + 216 + it("exits when createCategory fails in Step 4 (runtime error)", async () => { 217 + setupSteps1to3(); 218 + mockConfirm.mockResolvedValueOnce(true); 219 + mockCreateCategory.mockRejectedValueOnce(new Error("PDS write failed")); 220 + mockInput 221 + .mockResolvedValueOnce("General") 222 + .mockResolvedValueOnce(""); 223 + 224 + const run = getInitRun(); 225 + await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 226 + "process.exit:1" 227 + ); 228 + expect(mockCreateBoard).not.toHaveBeenCalled(); 229 + }); 230 + 231 + it("exits when createBoard fails in Step 4 (runtime error)", async () => { 232 + setupSteps1to3(); 233 + mockConfirm.mockResolvedValueOnce(true); 234 + mockCreateCategory.mockResolvedValueOnce({ 235 + created: true, 236 + skipped: false, 237 + uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 238 + cid: "bafycat", 239 + }); 240 + mockCreateBoard.mockRejectedValueOnce(new Error("Board PDS write failed")); 241 + mockInput 242 + .mockResolvedValueOnce("General") 243 + .mockResolvedValueOnce("") 244 + .mockResolvedValueOnce("General Discussion") 245 + .mockResolvedValueOnce(""); 246 + 247 + const run = getInitRun(); 248 + await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 249 + "process.exit:1" 250 + ); 251 + expect(exitSpy).toHaveBeenCalledWith(1); 252 + }); 253 + 254 + it("re-throws programming errors from createCategory in Step 4", async () => { 255 + setupSteps1to3(); 256 + mockConfirm.mockResolvedValueOnce(true); 257 + mockCreateCategory.mockRejectedValueOnce( 258 + new TypeError("Cannot read properties of undefined") 259 + ); 260 + mockInput 261 + .mockResolvedValueOnce("General") 262 + .mockResolvedValueOnce(""); 263 + 264 + const run = getInitRun(); 265 + await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow(TypeError); 266 + expect(exitSpy).not.toHaveBeenCalled(); 267 + }); 268 + 269 + it("re-throws programming errors from createBoard in Step 4", async () => { 270 + setupSteps1to3(); 271 + mockConfirm.mockResolvedValueOnce(true); 272 + mockCreateCategory.mockResolvedValueOnce({ 273 + created: true, 274 + skipped: false, 275 + uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 276 + cid: "bafycat", 277 + }); 278 + mockCreateBoard.mockRejectedValueOnce( 279 + new TypeError("Cannot read properties of undefined") 280 + ); 281 + mockInput 282 + .mockResolvedValueOnce("General") 283 + .mockResolvedValueOnce("") 284 + .mockResolvedValueOnce("General Discussion") 285 + .mockResolvedValueOnce(""); 286 + 287 + const run = getInitRun(); 288 + await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow(TypeError); 289 + expect(exitSpy).not.toHaveBeenCalled(); 290 + }); 291 + 292 + it("exits with accurate message when categoryId DB re-query throws", async () => { 293 + setupSteps1to3(); 294 + mockConfirm.mockResolvedValueOnce(true); 295 + mockCreateCategory.mockResolvedValueOnce({ 296 + created: true, 297 + skipped: false, 298 + uri: "at://did:plc:testforum/space.atbb.forum.category/cattid", 299 + cid: "bafycat", 300 + }); 301 + // First select call (categoryId re-query) throws 302 + mockDb.select.mockReturnValueOnce({ 303 + from: vi.fn().mockReturnValue({ 304 + where: vi.fn().mockReturnValue({ 305 + limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 306 + }), 307 + }), 308 + }); 309 + mockInput 310 + .mockResolvedValueOnce("General") 311 + .mockResolvedValueOnce(""); 312 + 313 + const run = getInitRun(); 314 + await expect(run({ args: { "forum-name": "Test Forum", owner: "owner.test" } })).rejects.toThrow( 315 + "process.exit:1" 316 + ); 317 + // Board creation should NOT be attempted after re-query failure 318 + expect(mockCreateBoard).not.toHaveBeenCalled(); 319 + }); 320 + });
+255
packages/cli/src/commands/board.ts
··· 1 + import { defineCommand } from "citty"; 2 + import consola from "consola"; 3 + import { input, select } from "@inquirer/prompts"; 4 + import postgres from "postgres"; 5 + import { drizzle } from "drizzle-orm/postgres-js"; 6 + import * as schema from "@atbb/db"; 7 + import { categories } from "@atbb/db"; 8 + import { eq, and } from "drizzle-orm"; 9 + import { ForumAgent } from "@atbb/atproto"; 10 + import { loadCliConfig } from "../lib/config.js"; 11 + import { checkEnvironment } from "../lib/preflight.js"; 12 + import { createBoard } from "../lib/steps/create-board.js"; 13 + import { isProgrammingError } from "../lib/errors.js"; 14 + 15 + const boardAddCommand = defineCommand({ 16 + meta: { 17 + name: "add", 18 + description: "Add a new board within a category", 19 + }, 20 + args: { 21 + "category-uri": { 22 + type: "string", 23 + description: "AT URI of the parent category (e.g. at://did/space.atbb.forum.category/rkey)", 24 + }, 25 + name: { 26 + type: "string", 27 + description: "Board name", 28 + }, 29 + description: { 30 + type: "string", 31 + description: "Board description (optional)", 32 + }, 33 + slug: { 34 + type: "string", 35 + description: "URL-friendly identifier (auto-derived from name if omitted)", 36 + }, 37 + "sort-order": { 38 + type: "string", 39 + description: "Numeric sort position — lower values appear first", 40 + }, 41 + }, 42 + async run({ args }) { 43 + consola.box("atBB — Add Board"); 44 + 45 + const config = loadCliConfig(); 46 + const envCheck = checkEnvironment(config); 47 + 48 + if (!envCheck.ok) { 49 + consola.error("Missing required environment variables:"); 50 + for (const name of envCheck.errors) { 51 + consola.error(` - ${name}`); 52 + } 53 + consola.info("Set these in your .env file or environment, then re-run."); 54 + process.exit(1); 55 + } 56 + 57 + const sql = postgres(config.databaseUrl); 58 + const db = drizzle(sql, { schema }); 59 + 60 + async function cleanup() { 61 + await sql.end(); 62 + } 63 + 64 + try { 65 + await sql`SELECT 1`; 66 + consola.success("Database connection successful"); 67 + } catch (error) { 68 + consola.error( 69 + "Failed to connect to database:", 70 + error instanceof Error ? error.message : String(error) 71 + ); 72 + await cleanup(); 73 + process.exit(1); 74 + } 75 + 76 + consola.start("Authenticating as Forum DID..."); 77 + const forumAgent = new ForumAgent( 78 + config.pdsUrl, 79 + config.forumHandle, 80 + config.forumPassword 81 + ); 82 + try { 83 + await forumAgent.initialize(); 84 + } catch (error) { 85 + consola.error( 86 + "Failed to reach PDS during authentication:", 87 + error instanceof Error ? error.message : String(error) 88 + ); 89 + try { await forumAgent.shutdown(); } catch {} 90 + await cleanup(); 91 + process.exit(1); 92 + } 93 + 94 + if (!forumAgent.isAuthenticated()) { 95 + const status = forumAgent.getStatus(); 96 + consola.error(`Failed to authenticate: ${status.error}`); 97 + await forumAgent.shutdown(); 98 + await cleanup(); 99 + process.exit(1); 100 + } 101 + 102 + const agent = forumAgent.getAgent()!; 103 + consola.success(`Authenticated as ${config.forumHandle}`); 104 + 105 + // Resolve parent category 106 + let categoryUri: string; 107 + let categoryId: bigint; 108 + let categoryCid: string; 109 + 110 + try { 111 + if (args["category-uri"]) { 112 + // Validate AT URI format before parsing 113 + const uri = args["category-uri"]; 114 + const parts = uri.split("/"); 115 + if (!uri.startsWith("at://") || parts.length < 5) { 116 + consola.error(`Invalid AT URI format: ${uri}`); 117 + consola.info("Expected format: at://did/space.atbb.forum.category/rkey"); 118 + await forumAgent.shutdown(); 119 + await cleanup(); 120 + process.exit(1); 121 + } 122 + 123 + // Validate that the collection segment is the expected category collection 124 + if (parts[3] !== "space.atbb.forum.category") { 125 + consola.error(`Invalid collection in URI: expected space.atbb.forum.category, got ${parts[3]}`); 126 + consola.info("Expected format: at://did/space.atbb.forum.category/rkey"); 127 + await forumAgent.shutdown(); 128 + await cleanup(); 129 + process.exit(1); 130 + } 131 + 132 + // Validate by looking it up in the DB 133 + // Parse AT URI: at://{did}/{collection}/{rkey} 134 + const did = parts[2]; 135 + const rkey = parts[parts.length - 1]; 136 + 137 + const [found] = await db 138 + .select() 139 + .from(categories) 140 + .where(and(eq(categories.did, did), eq(categories.rkey, rkey))) 141 + .limit(1); 142 + 143 + if (!found) { 144 + consola.error(`Category not found: ${uri}`); 145 + consola.info("Create it first with: atbb category add"); 146 + await forumAgent.shutdown(); 147 + await cleanup(); 148 + process.exit(1); 149 + } 150 + 151 + categoryUri = uri; 152 + categoryId = found.id; 153 + categoryCid = found.cid; 154 + } else { 155 + // Interactive selection from all categories in the forum 156 + const allCategories = await db 157 + .select() 158 + .from(categories) 159 + .where(eq(categories.did, config.forumDid)) 160 + .limit(100); 161 + 162 + if (allCategories.length === 0) { 163 + consola.error("No categories found in the database."); 164 + consola.info("Create one first with: atbb category add"); 165 + await forumAgent.shutdown(); 166 + await cleanup(); 167 + process.exit(1); 168 + } 169 + 170 + const chosen = await select({ 171 + message: "Select parent category:", 172 + choices: allCategories.map((c) => ({ 173 + name: c.description ? `${c.name} — ${c.description}` : c.name, 174 + value: c, 175 + })), 176 + }); 177 + 178 + categoryUri = `at://${chosen.did}/space.atbb.forum.category/${chosen.rkey}`; 179 + categoryId = chosen.id; 180 + categoryCid = chosen.cid; 181 + } 182 + } catch (error) { 183 + if (isProgrammingError(error)) throw error; 184 + consola.error( 185 + "Failed to resolve parent category:", 186 + JSON.stringify({ 187 + categoryUri: args["category-uri"], 188 + forumDid: config.forumDid, 189 + error: error instanceof Error ? error.message : String(error), 190 + }) 191 + ); 192 + await forumAgent.shutdown(); 193 + await cleanup(); 194 + process.exit(1); 195 + } 196 + 197 + const name = 198 + args.name ?? 199 + (await input({ message: "Board name:", default: "General Discussion" })); 200 + 201 + const description = 202 + args.description ?? 203 + (await input({ message: "Board description (optional):" })); 204 + 205 + const sortOrderRaw = args["sort-order"]; 206 + const sortOrder = 207 + sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined; 208 + 209 + try { 210 + const result = await createBoard(db, agent, config.forumDid, { 211 + name, 212 + ...(description && { description }), 213 + ...(args.slug && { slug: args.slug }), 214 + ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }), 215 + categoryUri, 216 + categoryId, 217 + categoryCid, 218 + }); 219 + 220 + if (result.skipped) { 221 + consola.warn(`Board "${result.existingName}" already exists: ${result.uri}`); 222 + } else { 223 + consola.success(`Created board "${name}"`); 224 + consola.info(`URI: ${result.uri}`); 225 + } 226 + } catch (error) { 227 + if (isProgrammingError(error)) throw error; 228 + consola.error( 229 + "Failed to create board:", 230 + JSON.stringify({ 231 + name: args.name, 232 + categoryUri, 233 + forumDid: config.forumDid, 234 + error: error instanceof Error ? error.message : String(error), 235 + }) 236 + ); 237 + await forumAgent.shutdown(); 238 + await cleanup(); 239 + process.exit(1); 240 + } 241 + 242 + await forumAgent.shutdown(); 243 + await cleanup(); 244 + }, 245 + }); 246 + 247 + export const boardCommand = defineCommand({ 248 + meta: { 249 + name: "board", 250 + description: "Manage forum boards", 251 + }, 252 + subCommands: { 253 + add: boardAddCommand, 254 + }, 255 + });
+153
packages/cli/src/commands/category.ts
··· 1 + import { defineCommand } from "citty"; 2 + import consola from "consola"; 3 + import { input } from "@inquirer/prompts"; 4 + import postgres from "postgres"; 5 + import { drizzle } from "drizzle-orm/postgres-js"; 6 + import * as schema from "@atbb/db"; 7 + import { ForumAgent } from "@atbb/atproto"; 8 + import { loadCliConfig } from "../lib/config.js"; 9 + import { checkEnvironment } from "../lib/preflight.js"; 10 + import { createCategory } from "../lib/steps/create-category.js"; 11 + import { isProgrammingError } from "../lib/errors.js"; 12 + 13 + const categoryAddCommand = defineCommand({ 14 + meta: { 15 + name: "add", 16 + description: "Add a new category to the forum", 17 + }, 18 + args: { 19 + name: { 20 + type: "string", 21 + description: "Category name", 22 + }, 23 + description: { 24 + type: "string", 25 + description: "Category description (optional)", 26 + }, 27 + slug: { 28 + type: "string", 29 + description: "URL-friendly identifier (auto-derived from name if omitted)", 30 + }, 31 + "sort-order": { 32 + type: "string", 33 + description: "Numeric sort position — lower values appear first", 34 + }, 35 + }, 36 + async run({ args }) { 37 + consola.box("atBB — Add Category"); 38 + 39 + const config = loadCliConfig(); 40 + const envCheck = checkEnvironment(config); 41 + 42 + if (!envCheck.ok) { 43 + consola.error("Missing required environment variables:"); 44 + for (const name of envCheck.errors) { 45 + consola.error(` - ${name}`); 46 + } 47 + consola.info("Set these in your .env file or environment, then re-run."); 48 + process.exit(1); 49 + } 50 + 51 + const sql = postgres(config.databaseUrl); 52 + const db = drizzle(sql, { schema }); 53 + 54 + async function cleanup() { 55 + await sql.end(); 56 + } 57 + 58 + try { 59 + await sql`SELECT 1`; 60 + consola.success("Database connection successful"); 61 + } catch (error) { 62 + consola.error( 63 + "Failed to connect to database:", 64 + error instanceof Error ? error.message : String(error) 65 + ); 66 + await cleanup(); 67 + process.exit(1); 68 + } 69 + 70 + consola.start("Authenticating as Forum DID..."); 71 + const forumAgent = new ForumAgent( 72 + config.pdsUrl, 73 + config.forumHandle, 74 + config.forumPassword 75 + ); 76 + try { 77 + await forumAgent.initialize(); 78 + } catch (error) { 79 + consola.error( 80 + "Failed to reach PDS during authentication:", 81 + error instanceof Error ? error.message : String(error) 82 + ); 83 + try { await forumAgent.shutdown(); } catch {} 84 + await cleanup(); 85 + process.exit(1); 86 + } 87 + 88 + if (!forumAgent.isAuthenticated()) { 89 + const status = forumAgent.getStatus(); 90 + consola.error(`Failed to authenticate: ${status.error}`); 91 + await forumAgent.shutdown(); 92 + await cleanup(); 93 + process.exit(1); 94 + } 95 + 96 + const agent = forumAgent.getAgent()!; 97 + consola.success(`Authenticated as ${config.forumHandle}`); 98 + 99 + const name = 100 + args.name ?? 101 + (await input({ message: "Category name:", default: "General" })); 102 + 103 + const description = 104 + args.description ?? 105 + (await input({ message: "Category description (optional):" })); 106 + 107 + const sortOrderRaw = args["sort-order"]; 108 + const sortOrder = 109 + sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined; 110 + 111 + try { 112 + const result = await createCategory(db, agent, config.forumDid, { 113 + name, 114 + ...(description && { description }), 115 + ...(args.slug && { slug: args.slug }), 116 + ...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }), 117 + }); 118 + 119 + if (result.skipped) { 120 + consola.warn(`Category "${result.existingName}" already exists: ${result.uri}`); 121 + } else { 122 + consola.success(`Created category "${name}"`); 123 + consola.info(`URI: ${result.uri}`); 124 + } 125 + } catch (error) { 126 + if (isProgrammingError(error)) throw error; 127 + consola.error( 128 + "Failed to create category:", 129 + JSON.stringify({ 130 + name, 131 + forumDid: config.forumDid, 132 + error: error instanceof Error ? error.message : String(error), 133 + }) 134 + ); 135 + await forumAgent.shutdown(); 136 + await cleanup(); 137 + process.exit(1); 138 + } 139 + 140 + await forumAgent.shutdown(); 141 + await cleanup(); 142 + }, 143 + }); 144 + 145 + export const categoryCommand = defineCommand({ 146 + meta: { 147 + name: "category", 148 + description: "Manage forum categories", 149 + }, 150 + subCommands: { 151 + add: categoryAddCommand, 152 + }, 153 + });
+137 -2
packages/cli/src/commands/init.ts
··· 1 1 import { defineCommand } from "citty"; 2 2 import consola from "consola"; 3 - import { input } from "@inquirer/prompts"; 3 + import { input, confirm } from "@inquirer/prompts"; 4 4 import postgres from "postgres"; 5 5 import { drizzle } from "drizzle-orm/postgres-js"; 6 6 import * as schema from "@atbb/db"; ··· 10 10 import { createForumRecord } from "../lib/steps/create-forum.js"; 11 11 import { seedDefaultRoles } from "../lib/steps/seed-roles.js"; 12 12 import { assignOwnerRole } from "../lib/steps/assign-owner.js"; 13 + import { categories } from "@atbb/db"; 14 + import { eq, and } from "drizzle-orm"; 15 + import { createCategory } from "../lib/steps/create-category.js"; 16 + import { createBoard } from "../lib/steps/create-board.js"; 17 + import { isProgrammingError } from "../lib/errors.js"; 13 18 14 19 export const initCommand = defineCommand({ 15 20 meta: { ··· 175 180 process.exit(1); 176 181 } 177 182 183 + // Step 4: Seed initial categories and boards (optional) 184 + consola.log(""); 185 + consola.info("Step 4: Seed Initial Structure"); 186 + 187 + const shouldSeed = await confirm({ 188 + message: "Seed an initial category and board?", 189 + default: true, 190 + }); 191 + 192 + if (shouldSeed) { 193 + const categoryName = await input({ 194 + message: "Category name:", 195 + default: "General", 196 + }); 197 + 198 + const categoryDescription = await input({ 199 + message: "Category description (optional):", 200 + }); 201 + 202 + let categoryUri: string | undefined; 203 + let categoryId: bigint | undefined; 204 + let categoryCid: string | undefined; 205 + 206 + try { 207 + const categoryResult = await createCategory(db, agent, config.forumDid, { 208 + name: categoryName, 209 + ...(categoryDescription && { description: categoryDescription }), 210 + }); 211 + 212 + if (categoryResult.skipped) { 213 + consola.warn(`Category "${categoryResult.existingName}" already exists`); 214 + } else { 215 + consola.success(`Created category "${categoryName}": ${categoryResult.uri}`); 216 + } 217 + 218 + categoryUri = categoryResult.uri; 219 + categoryCid = categoryResult.cid; 220 + } catch (error) { 221 + if (isProgrammingError(error)) throw error; 222 + consola.error( 223 + "Failed to create category:", 224 + JSON.stringify({ 225 + name: categoryName, 226 + forumDid: config.forumDid, 227 + error: error instanceof Error ? error.message : String(error), 228 + }) 229 + ); 230 + await forumAgent.shutdown(); 231 + await cleanup(); 232 + process.exit(1); 233 + } 234 + 235 + // Look up the categoryId from DB separately so a re-query failure doesn't 236 + // report as "Failed to create category" (the PDS write already succeeded above) 237 + try { 238 + const parts = categoryUri!.split("/"); 239 + const rkey = parts[parts.length - 1]; 240 + const [cat] = await db 241 + .select() 242 + .from(categories) 243 + .where(and(eq(categories.did, config.forumDid), eq(categories.rkey, rkey))) 244 + .limit(1); 245 + categoryId = cat?.id; 246 + } catch (error) { 247 + if (isProgrammingError(error)) throw error; 248 + consola.error( 249 + "Failed to look up category ID after creation:", 250 + JSON.stringify({ 251 + categoryUri, 252 + forumDid: config.forumDid, 253 + error: error instanceof Error ? error.message : String(error), 254 + }) 255 + ); 256 + await forumAgent.shutdown(); 257 + await cleanup(); 258 + process.exit(1); 259 + } 260 + 261 + if (!categoryId) { 262 + consola.error("Failed to look up category ID after creation. Cannot create board."); 263 + await forumAgent.shutdown(); 264 + await cleanup(); 265 + process.exit(1); 266 + } 267 + 268 + // At this point categoryUri, categoryId, and categoryCid are guaranteed set 269 + // (the !categoryId guard above exits the process if the DB lookup fails) 270 + const boardName = await input({ 271 + message: "Board name:", 272 + default: "General Discussion", 273 + }); 274 + 275 + const boardDescription = await input({ 276 + message: "Board description (optional):", 277 + }); 278 + 279 + try { 280 + const boardResult = await createBoard(db, agent, config.forumDid, { 281 + name: boardName, 282 + ...(boardDescription && { description: boardDescription }), 283 + categoryUri: categoryUri!, 284 + categoryId: categoryId!, 285 + categoryCid: categoryCid!, 286 + }); 287 + 288 + if (boardResult.skipped) { 289 + consola.warn(`Board "${boardResult.existingName}" already exists`); 290 + } else { 291 + consola.success(`Created board "${boardName}": ${boardResult.uri}`); 292 + } 293 + } catch (error) { 294 + if (isProgrammingError(error)) throw error; 295 + consola.error( 296 + "Failed to create board:", 297 + JSON.stringify({ 298 + name: boardName, 299 + categoryUri, 300 + forumDid: config.forumDid, 301 + error: error instanceof Error ? error.message : String(error), 302 + }) 303 + ); 304 + await forumAgent.shutdown(); 305 + await cleanup(); 306 + process.exit(1); 307 + } 308 + } else { 309 + consola.info("Skipped. Add categories later with: atbb category add"); 310 + } 311 + 178 312 // Done — close connections 179 313 await forumAgent.shutdown(); 180 314 await cleanup(); ··· 187 321 " 1. Start the appview: pnpm --filter @atbb/appview dev", 188 322 " 2. Start the web UI: pnpm --filter @atbb/web dev", 189 323 ` 3. Log in as ${ownerInput} to access admin features`, 190 - " 4. Create categories and boards from the admin panel", 324 + " 4. Add more boards: atbb board add", 325 + " 5. Add more categories: atbb category add", 191 326 ].join("\n"), 192 327 }); 193 328 },
+4
packages/cli/src/index.ts
··· 1 1 #!/usr/bin/env node 2 2 import { defineCommand, runMain } from "citty"; 3 3 import { initCommand } from "./commands/init.js"; 4 + import { categoryCommand } from "./commands/category.js"; 5 + import { boardCommand } from "./commands/board.js"; 4 6 5 7 const main = defineCommand({ 6 8 meta: { ··· 10 12 }, 11 13 subCommands: { 12 14 init: initCommand, 15 + category: categoryCommand, 16 + board: boardCommand, 13 17 }, 14 18 }); 15 19
+18
packages/cli/src/lib/errors.ts
··· 1 + /** 2 + * Returns true for errors that indicate a programming bug rather than a 3 + * runtime failure (network down, DB unavailable, bad user input, etc.). 4 + * 5 + * These should NOT be caught and swallowed — re-throw them so they surface 6 + * clearly during development instead of being silently logged. 7 + * 8 + * - TypeError: accessing a property on null/undefined, calling a non-function 9 + * - ReferenceError: using an undeclared variable 10 + * - SyntaxError: malformed JSON.parse in our own code 11 + */ 12 + export function isProgrammingError(error: unknown): boolean { 13 + return ( 14 + error instanceof TypeError || 15 + error instanceof ReferenceError || 16 + error instanceof SyntaxError 17 + ); 18 + }
+14
packages/cli/src/lib/slug.ts
··· 1 + /** 2 + * Derives a URL-friendly slug from a display name. 3 + * Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens. 4 + * 5 + * Examples: 6 + * "My Cool Category" → "my-cool-category" 7 + * " Off-Topic Chat! " → "off-topic-chat" 8 + */ 9 + export function deriveSlug(name: string): string { 10 + return name 11 + .toLowerCase() 12 + .replace(/[^a-z0-9]+/g, "-") 13 + .replace(/^-|-$/g, ""); 14 + }
+104
packages/cli/src/lib/steps/create-board.ts
··· 1 + import type { AtpAgent } from "@atproto/api"; 2 + import type { Database } from "@atbb/db"; 3 + import { boards } from "@atbb/db"; 4 + import { eq, and } from "drizzle-orm"; 5 + import { deriveSlug } from "../slug.js"; 6 + 7 + interface CreateBoardInput { 8 + name: string; 9 + description?: string; 10 + slug?: string; 11 + sortOrder?: number; 12 + categoryUri: string; // AT URI: at://did/space.atbb.forum.category/rkey 13 + categoryId: bigint; // DB FK 14 + categoryCid: string; // CID for the category strongRef 15 + } 16 + 17 + interface CreateBoardResult { 18 + created: boolean; 19 + skipped: boolean; 20 + uri?: string; 21 + cid?: string; 22 + existingName?: string; 23 + } 24 + 25 + /** 26 + * Create a space.atbb.forum.board record on the Forum DID's PDS 27 + * and insert it into the database. 28 + * Idempotent: skips if a board with the same name in the same category exists. 29 + */ 30 + export async function createBoard( 31 + db: Database, 32 + agent: AtpAgent, 33 + forumDid: string, 34 + input: CreateBoardInput 35 + ): Promise<CreateBoardResult> { 36 + // Check if board with this name already exists in the category 37 + const [existing] = await db 38 + .select() 39 + .from(boards) 40 + .where( 41 + and( 42 + eq(boards.did, forumDid), 43 + eq(boards.name, input.name), 44 + eq(boards.categoryUri, input.categoryUri) 45 + ) 46 + ) 47 + .limit(1); 48 + 49 + if (existing) { 50 + return { 51 + created: false, 52 + skipped: true, 53 + uri: `at://${existing.did}/space.atbb.forum.board/${existing.rkey}`, 54 + cid: existing.cid, 55 + existingName: existing.name, 56 + }; 57 + } 58 + 59 + const slug = input.slug ?? deriveSlug(input.name); 60 + const now = new Date(); 61 + 62 + const response = await agent.com.atproto.repo.createRecord({ 63 + repo: forumDid, 64 + collection: "space.atbb.forum.board", 65 + record: { 66 + $type: "space.atbb.forum.board", 67 + name: input.name, 68 + ...(input.description && { description: input.description }), 69 + slug, 70 + ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }), 71 + // categoryRef shape: { category: strongRef } 72 + category: { 73 + category: { 74 + uri: input.categoryUri, 75 + cid: input.categoryCid, 76 + }, 77 + }, 78 + createdAt: now.toISOString(), 79 + }, 80 + }); 81 + 82 + const rkey = response.data.uri.split("/").pop()!; 83 + 84 + await db.insert(boards).values({ 85 + did: forumDid, 86 + rkey, 87 + cid: response.data.cid, 88 + name: input.name, 89 + description: input.description ?? null, 90 + slug, 91 + sortOrder: input.sortOrder ?? null, 92 + categoryId: input.categoryId, 93 + categoryUri: input.categoryUri, 94 + createdAt: now, 95 + indexedAt: now, 96 + }); 97 + 98 + return { 99 + created: true, 100 + skipped: false, 101 + uri: response.data.uri, 102 + cid: response.data.cid, 103 + }; 104 + }
+101
packages/cli/src/lib/steps/create-category.ts
··· 1 + import type { AtpAgent } from "@atproto/api"; 2 + import type { Database } from "@atbb/db"; 3 + import { categories, forums } from "@atbb/db"; 4 + import { eq, and } from "drizzle-orm"; 5 + import { deriveSlug } from "../slug.js"; 6 + 7 + interface CreateCategoryInput { 8 + name: string; 9 + description?: string; 10 + slug?: string; 11 + sortOrder?: number; 12 + } 13 + 14 + interface CreateCategoryResult { 15 + created: boolean; 16 + skipped: boolean; 17 + uri?: string; 18 + cid?: string; 19 + existingName?: string; 20 + } 21 + 22 + /** 23 + * Create a space.atbb.forum.category record on the Forum DID's PDS 24 + * and insert it into the database. 25 + * Idempotent: skips if a category with the same name already exists. 26 + */ 27 + export async function createCategory( 28 + db: Database, 29 + agent: AtpAgent, 30 + forumDid: string, 31 + input: CreateCategoryInput 32 + ): Promise<CreateCategoryResult> { 33 + // Check if category with this name already exists 34 + const [existing] = await db 35 + .select() 36 + .from(categories) 37 + .where(and(eq(categories.did, forumDid), eq(categories.name, input.name))) 38 + .limit(1); 39 + 40 + if (existing) { 41 + return { 42 + created: false, 43 + skipped: true, 44 + uri: `at://${existing.did}/space.atbb.forum.category/${existing.rkey}`, 45 + cid: existing.cid, 46 + existingName: existing.name, 47 + }; 48 + } 49 + 50 + // Look up forum row for FK reference (optional — null if forum not yet in DB) 51 + const [forum] = await db 52 + .select() 53 + .from(forums) 54 + .where(and(eq(forums.did, forumDid), eq(forums.rkey, "self"))) 55 + .limit(1); 56 + 57 + if (!forum?.id) { 58 + console.warn( 59 + "Forum record not found in DB — inserting category with forumId: null", 60 + JSON.stringify({ forumDid }) 61 + ); 62 + } 63 + 64 + const slug = input.slug ?? deriveSlug(input.name); 65 + const now = new Date(); 66 + 67 + const response = await agent.com.atproto.repo.createRecord({ 68 + repo: forumDid, 69 + collection: "space.atbb.forum.category", 70 + record: { 71 + $type: "space.atbb.forum.category", 72 + name: input.name, 73 + ...(input.description && { description: input.description }), 74 + slug, 75 + ...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }), 76 + createdAt: now.toISOString(), 77 + }, 78 + }); 79 + 80 + const rkey = response.data.uri.split("/").pop()!; 81 + 82 + await db.insert(categories).values({ 83 + did: forumDid, 84 + rkey, 85 + cid: response.data.cid, 86 + name: input.name, 87 + description: input.description ?? null, 88 + slug, 89 + sortOrder: input.sortOrder ?? null, 90 + forumId: forum?.id ?? null, 91 + createdAt: now, 92 + indexedAt: now, 93 + }); 94 + 95 + return { 96 + created: true, 97 + skipped: false, 98 + uri: response.data.uri, 99 + cid: response.data.cid, 100 + }; 101 + }