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

docs: bootstrap CLI implementation plan

12-task TDD implementation plan for atbb init command. Covers
packages/atproto extraction, packages/cli scaffolding, bootstrap
steps (create-forum, seed-roles, assign-owner), and Dockerfile updates.

+1729
+1729
docs/plans/2026-02-18-bootstrap-cli-implementation.md
··· 1 + # Bootstrap CLI Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build an `atbb init` CLI command that bootstraps a new forum instance — creating the forum PDS record, seeding roles, and assigning the first Owner. 6 + 7 + **Architecture:** Two new workspace packages: `packages/atproto` (shared ForumAgent + error helpers extracted from appview) and `packages/cli` (the CLI tool). The CLI authenticates as the Forum DID, writes records to the PDS, and assigns the Owner role. Each bootstrap step is idempotent and independently testable. 8 + 9 + **Tech Stack:** citty (CLI framework), consola (styled output), @inquirer/prompts (interactive input), @atproto/api (PDS operations), @atbb/db (database), vitest (testing). 10 + 11 + **Design doc:** `docs/plans/2026-02-18-bootstrap-cli-design.md` 12 + 13 + --- 14 + 15 + ## Task 1: Create `packages/atproto` package scaffolding 16 + 17 + **Files:** 18 + - Create: `packages/atproto/package.json` 19 + - Create: `packages/atproto/tsconfig.json` 20 + - Create: `packages/atproto/src/index.ts` 21 + 22 + **Step 1: Create package.json** 23 + 24 + ```json 25 + { 26 + "name": "@atbb/atproto", 27 + "version": "0.1.0", 28 + "private": true, 29 + "type": "module", 30 + "main": "./dist/index.js", 31 + "types": "./dist/index.d.ts", 32 + "exports": { 33 + ".": { 34 + "types": "./dist/index.d.ts", 35 + "default": "./dist/index.js" 36 + } 37 + }, 38 + "scripts": { 39 + "build": "tsc", 40 + "lint": "tsc --noEmit", 41 + "lint:fix": "oxlint --fix src/", 42 + "clean": "rm -rf dist", 43 + "test": "vitest run" 44 + }, 45 + "dependencies": { 46 + "@atproto/api": "^0.15.0" 47 + }, 48 + "devDependencies": { 49 + "@types/node": "^22.0.0", 50 + "typescript": "^5.7.0" 51 + } 52 + } 53 + ``` 54 + 55 + **Step 2: Create tsconfig.json** 56 + 57 + ```json 58 + { 59 + "extends": "../../tsconfig.base.json", 60 + "compilerOptions": { 61 + "outDir": "./dist", 62 + "rootDir": "./src" 63 + }, 64 + "include": ["src/**/*.ts"] 65 + } 66 + ``` 67 + 68 + **Step 3: Create empty index.ts** 69 + 70 + ```typescript 71 + // @atbb/atproto — Shared AT Protocol utilities 72 + // Exports will be added as modules are extracted from appview. 73 + ``` 74 + 75 + **Step 4: Install dependencies** 76 + 77 + Run: `pnpm install` 78 + 79 + **Step 5: Verify build** 80 + 81 + Run: `pnpm --filter @atbb/atproto build` 82 + Expected: Clean build, `packages/atproto/dist/index.js` exists. 83 + 84 + **Step 6: Commit** 85 + 86 + ```bash 87 + git add packages/atproto/ 88 + git commit -m "chore: scaffold @atbb/atproto package" 89 + ``` 90 + 91 + --- 92 + 93 + ## Task 2: Extract error helpers into `packages/atproto` 94 + 95 + **Files:** 96 + - Create: `packages/atproto/src/errors.ts` 97 + - Create: `packages/atproto/src/__tests__/errors.test.ts` 98 + - Modify: `packages/atproto/src/index.ts` 99 + - Modify: `apps/appview/src/lib/errors.ts` 100 + - Modify: `apps/appview/src/routes/posts.ts:7` 101 + - Modify: `apps/appview/src/routes/admin.ts:8` 102 + - Modify: `apps/appview/src/routes/mod.ts:7` 103 + - Modify: `apps/appview/src/routes/topics.ts:10` 104 + - Modify: `apps/appview/src/lib/ban-enforcer.ts:4` 105 + - Modify: `apps/appview/src/routes/__tests__/helpers.test.ts:2` 106 + 107 + **Step 1: Write the error helper tests** 108 + 109 + Create `packages/atproto/src/__tests__/errors.test.ts`: 110 + 111 + ```typescript 112 + import { describe, it, expect } from "vitest"; 113 + import { isProgrammingError, isNetworkError, isAuthError, isDatabaseError } from "../errors.js"; 114 + 115 + describe("isProgrammingError", () => { 116 + it("returns true for TypeError", () => { 117 + expect(isProgrammingError(new TypeError("x is not a function"))).toBe(true); 118 + }); 119 + 120 + it("returns true for ReferenceError", () => { 121 + expect(isProgrammingError(new ReferenceError("x is not defined"))).toBe(true); 122 + }); 123 + 124 + it("returns true for SyntaxError", () => { 125 + expect(isProgrammingError(new SyntaxError("unexpected token"))).toBe(true); 126 + }); 127 + 128 + it("returns false for generic Error", () => { 129 + expect(isProgrammingError(new Error("something failed"))).toBe(false); 130 + }); 131 + 132 + it("returns false for non-error values", () => { 133 + expect(isProgrammingError("string")).toBe(false); 134 + expect(isProgrammingError(null)).toBe(false); 135 + }); 136 + }); 137 + 138 + describe("isNetworkError", () => { 139 + it("returns true for fetch failed", () => { 140 + expect(isNetworkError(new Error("fetch failed"))).toBe(true); 141 + }); 142 + 143 + it("returns true for ECONNREFUSED", () => { 144 + expect(isNetworkError(new Error("ECONNREFUSED"))).toBe(true); 145 + }); 146 + 147 + it("returns true for timeout", () => { 148 + expect(isNetworkError(new Error("request timeout"))).toBe(true); 149 + }); 150 + 151 + it("returns false for generic Error", () => { 152 + expect(isNetworkError(new Error("something else"))).toBe(false); 153 + }); 154 + 155 + it("returns false for non-Error values", () => { 156 + expect(isNetworkError("string")).toBe(false); 157 + }); 158 + }); 159 + 160 + describe("isAuthError", () => { 161 + it("returns true for invalid credentials", () => { 162 + expect(isAuthError(new Error("Invalid identifier or password"))).toBe(true); 163 + }); 164 + 165 + it("returns true for authentication failed", () => { 166 + expect(isAuthError(new Error("Authentication failed"))).toBe(true); 167 + }); 168 + 169 + it("returns true for unauthorized", () => { 170 + expect(isAuthError(new Error("Unauthorized"))).toBe(true); 171 + }); 172 + 173 + it("returns false for network errors", () => { 174 + expect(isAuthError(new Error("fetch failed"))).toBe(false); 175 + }); 176 + 177 + it("returns false for non-Error values", () => { 178 + expect(isAuthError("string")).toBe(false); 179 + }); 180 + }); 181 + 182 + describe("isDatabaseError", () => { 183 + it("returns true for pool errors", () => { 184 + expect(isDatabaseError(new Error("pool exhausted"))).toBe(true); 185 + }); 186 + 187 + it("returns true for postgres errors", () => { 188 + expect(isDatabaseError(new Error("postgres connection lost"))).toBe(true); 189 + }); 190 + 191 + it("returns false for generic errors", () => { 192 + expect(isDatabaseError(new Error("something else"))).toBe(false); 193 + }); 194 + }); 195 + ``` 196 + 197 + **Step 2: Run tests to verify they fail** 198 + 199 + Run: `pnpm --filter @atbb/atproto test` 200 + Expected: FAIL — `errors.js` module does not exist yet. 201 + 202 + **Step 3: Create `packages/atproto/src/errors.ts`** 203 + 204 + Copy the error helpers from `apps/appview/src/lib/errors.ts` and add `isAuthError` from `apps/appview/src/lib/forum-agent.ts`: 205 + 206 + ```typescript 207 + /** 208 + * Check if an error is a programming error (code bug). 209 + * Programming errors should be re-thrown, not caught. 210 + */ 211 + export function isProgrammingError(error: unknown): boolean { 212 + return ( 213 + error instanceof TypeError || 214 + error instanceof ReferenceError || 215 + error instanceof SyntaxError 216 + ); 217 + } 218 + 219 + /** 220 + * Check if an error is a network error (temporary). 221 + * Network errors should return 503 (retry later). 222 + */ 223 + export function isNetworkError(error: unknown): boolean { 224 + if (!(error instanceof Error)) return false; 225 + const msg = error.message.toLowerCase(); 226 + return ( 227 + msg.includes("fetch failed") || 228 + msg.includes("network") || 229 + msg.includes("econnrefused") || 230 + msg.includes("enotfound") || 231 + msg.includes("timeout") || 232 + msg.includes("econnreset") || 233 + msg.includes("enetunreach") || 234 + msg.includes("service unavailable") 235 + ); 236 + } 237 + 238 + /** 239 + * Check if an error is an authentication error (wrong credentials). 240 + * Auth errors should NOT be retried to avoid account lockouts. 241 + */ 242 + export function isAuthError(error: unknown): boolean { 243 + if (!(error instanceof Error)) return false; 244 + const message = error.message.toLowerCase(); 245 + return ( 246 + message.includes("invalid identifier") || 247 + message.includes("invalid password") || 248 + message.includes("authentication failed") || 249 + message.includes("unauthorized") 250 + ); 251 + } 252 + 253 + /** 254 + * Check if an error represents a database-layer failure. 255 + * These errors indicate temporary unavailability — user should retry. 256 + */ 257 + export function isDatabaseError(error: unknown): boolean { 258 + if (!(error instanceof Error)) return false; 259 + const msg = error.message.toLowerCase(); 260 + return ( 261 + msg.includes("pool") || 262 + msg.includes("postgres") || 263 + msg.includes("database") || 264 + msg.includes("sql") || 265 + msg.includes("query") 266 + ); 267 + } 268 + ``` 269 + 270 + **Step 4: Update `packages/atproto/src/index.ts`** 271 + 272 + ```typescript 273 + export { 274 + isProgrammingError, 275 + isNetworkError, 276 + isAuthError, 277 + isDatabaseError, 278 + } from "./errors.js"; 279 + ``` 280 + 281 + **Step 5: Run tests to verify they pass** 282 + 283 + Run: `pnpm --filter @atbb/atproto test` 284 + Expected: All PASS. 285 + 286 + **Step 6: Update appview to import from `@atbb/atproto`** 287 + 288 + Add `@atbb/atproto` dependency to `apps/appview/package.json`: 289 + 290 + ```json 291 + "@atbb/atproto": "workspace:*" 292 + ``` 293 + 294 + Then run: `pnpm install` 295 + 296 + Replace `apps/appview/src/lib/errors.ts` with a re-export shim: 297 + 298 + ```typescript 299 + // Re-export from shared package for backward compatibility. 300 + // Appview routes can gradually migrate to importing from @atbb/atproto directly. 301 + export { 302 + isProgrammingError, 303 + isNetworkError, 304 + isAuthError, 305 + isDatabaseError, 306 + } from "@atbb/atproto"; 307 + ``` 308 + 309 + **Step 7: Verify appview still builds and tests pass** 310 + 311 + Run: `pnpm build && pnpm --filter @atbb/appview test` 312 + Expected: All pass — no behavioral changes. 313 + 314 + **Step 8: Commit** 315 + 316 + ```bash 317 + git add packages/atproto/ apps/appview/package.json apps/appview/src/lib/errors.ts pnpm-lock.yaml 318 + git commit -m "refactor: extract error helpers into @atbb/atproto" 319 + ``` 320 + 321 + --- 322 + 323 + ## Task 3: Move ForumAgent into `packages/atproto` 324 + 325 + **Files:** 326 + - Create: `packages/atproto/src/forum-agent.ts` 327 + - Create: `packages/atproto/src/__tests__/forum-agent.test.ts` 328 + - Modify: `packages/atproto/src/index.ts` 329 + - Modify: `apps/appview/src/lib/app-context.ts:7` 330 + - Delete: `apps/appview/src/lib/forum-agent.ts` 331 + - Delete: `apps/appview/src/lib/__tests__/forum-agent.test.ts` 332 + 333 + **Step 1: Copy ForumAgent to packages/atproto** 334 + 335 + Copy `apps/appview/src/lib/forum-agent.ts` → `packages/atproto/src/forum-agent.ts`. 336 + 337 + The only change: replace the local `isAuthError` / `isNetworkError` helper functions at the top of the file with an import: 338 + 339 + ```typescript 340 + import { isAuthError, isNetworkError } from "./errors.js"; 341 + ``` 342 + 343 + Remove the `isAuthError` and `isNetworkError` function definitions from the file (lines 7-39 of the original). They now live in `errors.ts`. 344 + 345 + **Step 2: Copy ForumAgent tests** 346 + 347 + Copy `apps/appview/src/lib/__tests__/forum-agent.test.ts` → `packages/atproto/src/__tests__/forum-agent.test.ts`. 348 + 349 + Only change the import path: 350 + 351 + ```typescript 352 + import { ForumAgent } from "../forum-agent.js"; 353 + ``` 354 + 355 + (This is already the correct relative path — it doesn't change.) 356 + 357 + **Step 3: Update `packages/atproto/src/index.ts`** 358 + 359 + ```typescript 360 + export { 361 + isProgrammingError, 362 + isNetworkError, 363 + isAuthError, 364 + isDatabaseError, 365 + } from "./errors.js"; 366 + 367 + export { ForumAgent } from "./forum-agent.js"; 368 + export type { ForumAgentStatus, ForumAgentState } from "./forum-agent.js"; 369 + ``` 370 + 371 + **Step 4: Run atproto tests** 372 + 373 + Run: `pnpm --filter @atbb/atproto test` 374 + Expected: All ForumAgent tests + error tests pass. 375 + 376 + **Step 5: Update appview imports** 377 + 378 + In `apps/appview/src/lib/app-context.ts`, change line 7: 379 + ```typescript 380 + // Before: 381 + import { ForumAgent } from "./forum-agent.js"; 382 + 383 + // After: 384 + import { ForumAgent } from "@atbb/atproto"; 385 + ``` 386 + 387 + Also update `apps/appview/src/lib/app-context.ts` line 8 — remove the `AppConfig` type import from `"./config.js"` if it imported ForumAgent types (it doesn't — just verify). 388 + 389 + **Step 6: Delete old files from appview** 390 + 391 + Delete: 392 + - `apps/appview/src/lib/forum-agent.ts` 393 + - `apps/appview/src/lib/__tests__/forum-agent.test.ts` 394 + 395 + **Step 7: Verify everything builds and tests pass** 396 + 397 + Run: `pnpm build && pnpm test` 398 + Expected: All packages build. All tests pass. ForumAgent tests now run under `@atbb/atproto` instead of `@atbb/appview`. 399 + 400 + **Step 8: Commit** 401 + 402 + ```bash 403 + git add packages/atproto/ apps/appview/ pnpm-lock.yaml 404 + git commit -m "refactor: move ForumAgent to @atbb/atproto package" 405 + ``` 406 + 407 + --- 408 + 409 + ## Task 4: Add identity resolution to `packages/atproto` 410 + 411 + **Files:** 412 + - Create: `packages/atproto/src/resolve-identity.ts` 413 + - Create: `packages/atproto/src/__tests__/resolve-identity.test.ts` 414 + - Modify: `packages/atproto/src/index.ts` 415 + 416 + **Step 1: Write the failing tests** 417 + 418 + Create `packages/atproto/src/__tests__/resolve-identity.test.ts`: 419 + 420 + ```typescript 421 + import { describe, it, expect, vi } from "vitest"; 422 + import { resolveIdentity } from "../resolve-identity.js"; 423 + import { AtpAgent } from "@atproto/api"; 424 + 425 + vi.mock("@atproto/api", () => ({ 426 + AtpAgent: vi.fn(), 427 + })); 428 + 429 + describe("resolveIdentity", () => { 430 + it("returns DID directly when input starts with 'did:'", async () => { 431 + const result = await resolveIdentity("did:plc:abc123", "https://bsky.social"); 432 + 433 + expect(result).toEqual({ did: "did:plc:abc123" }); 434 + // AtpAgent should NOT be instantiated for DID input 435 + expect(AtpAgent).not.toHaveBeenCalled(); 436 + }); 437 + 438 + it("resolves a handle to a DID via PDS", async () => { 439 + const mockResolveHandle = vi.fn().mockResolvedValue({ 440 + data: { did: "did:plc:resolved123" }, 441 + }); 442 + (AtpAgent as any).mockImplementation(() => ({ 443 + resolveHandle: mockResolveHandle, 444 + })); 445 + 446 + const result = await resolveIdentity("alice.bsky.social", "https://bsky.social"); 447 + 448 + expect(result).toEqual({ 449 + did: "did:plc:resolved123", 450 + handle: "alice.bsky.social", 451 + }); 452 + expect(AtpAgent).toHaveBeenCalledWith({ service: "https://bsky.social" }); 453 + expect(mockResolveHandle).toHaveBeenCalledWith({ handle: "alice.bsky.social" }); 454 + }); 455 + 456 + it("throws when handle resolution fails", async () => { 457 + (AtpAgent as any).mockImplementation(() => ({ 458 + resolveHandle: vi.fn().mockRejectedValue(new Error("Unable to resolve handle")), 459 + })); 460 + 461 + await expect( 462 + resolveIdentity("nonexistent.bsky.social", "https://bsky.social") 463 + ).rejects.toThrow("Unable to resolve handle"); 464 + }); 465 + }); 466 + ``` 467 + 468 + **Step 2: Run tests to verify they fail** 469 + 470 + Run: `pnpm --filter @atbb/atproto test` 471 + Expected: FAIL — `resolve-identity.js` does not exist. 472 + 473 + **Step 3: Implement resolve-identity** 474 + 475 + Create `packages/atproto/src/resolve-identity.ts`: 476 + 477 + ```typescript 478 + import { AtpAgent } from "@atproto/api"; 479 + 480 + export interface ResolvedIdentity { 481 + did: string; 482 + handle?: string; 483 + } 484 + 485 + /** 486 + * Resolve a handle or DID string to a confirmed DID. 487 + * If the input already starts with "did:", returns it directly. 488 + * Otherwise, treats it as a handle and resolves via the PDS. 489 + */ 490 + export async function resolveIdentity( 491 + input: string, 492 + pdsUrl: string 493 + ): Promise<ResolvedIdentity> { 494 + if (input.startsWith("did:")) { 495 + return { did: input }; 496 + } 497 + 498 + const agent = new AtpAgent({ service: pdsUrl }); 499 + const res = await agent.resolveHandle({ handle: input }); 500 + return { did: res.data.did, handle: input }; 501 + } 502 + ``` 503 + 504 + **Step 4: Update `packages/atproto/src/index.ts`** 505 + 506 + Add the export: 507 + 508 + ```typescript 509 + export { resolveIdentity } from "./resolve-identity.js"; 510 + export type { ResolvedIdentity } from "./resolve-identity.js"; 511 + ``` 512 + 513 + **Step 5: Run tests to verify they pass** 514 + 515 + Run: `pnpm --filter @atbb/atproto test` 516 + Expected: All pass. 517 + 518 + **Step 6: Commit** 519 + 520 + ```bash 521 + git add packages/atproto/ 522 + git commit -m "feat: add identity resolution helper to @atbb/atproto" 523 + ``` 524 + 525 + --- 526 + 527 + ## Task 5: Create `packages/cli` package scaffolding 528 + 529 + **Files:** 530 + - Create: `packages/cli/package.json` 531 + - Create: `packages/cli/tsconfig.json` 532 + - Create: `packages/cli/src/index.ts` 533 + 534 + **Step 1: Create package.json** 535 + 536 + ```json 537 + { 538 + "name": "@atbb/cli", 539 + "version": "0.1.0", 540 + "private": true, 541 + "type": "module", 542 + "bin": { 543 + "atbb": "./dist/index.js" 544 + }, 545 + "scripts": { 546 + "build": "tsc", 547 + "dev": "tsx --env-file=../../.env src/index.ts", 548 + "lint": "tsc --noEmit", 549 + "lint:fix": "oxlint --fix src/", 550 + "clean": "rm -rf dist", 551 + "test": "vitest run" 552 + }, 553 + "dependencies": { 554 + "@atbb/atproto": "workspace:*", 555 + "@atbb/db": "workspace:*", 556 + "@atproto/api": "^0.15.0", 557 + "citty": "^0.1.6", 558 + "consola": "^3.4.0" 559 + }, 560 + "devDependencies": { 561 + "@inquirer/prompts": "^7.0.0", 562 + "@types/node": "^22.0.0", 563 + "tsx": "^4.0.0", 564 + "typescript": "^5.7.0" 565 + } 566 + } 567 + ``` 568 + 569 + Note: `@inquirer/prompts` is in devDependencies for now — we'll move it to dependencies once we implement interactive prompts in Task 8. For Task 5 we only need the shell. 570 + 571 + **Step 2: Create tsconfig.json** 572 + 573 + ```json 574 + { 575 + "extends": "../../tsconfig.base.json", 576 + "compilerOptions": { 577 + "outDir": "./dist", 578 + "rootDir": "./src" 579 + }, 580 + "include": ["src/**/*.ts"] 581 + } 582 + ``` 583 + 584 + **Step 3: Create minimal CLI entrypoint** 585 + 586 + Create `packages/cli/src/index.ts`: 587 + 588 + ```typescript 589 + #!/usr/bin/env node 590 + import { defineCommand, runMain } from "citty"; 591 + 592 + const main = defineCommand({ 593 + meta: { 594 + name: "atbb", 595 + version: "0.1.0", 596 + description: "atBB Forum management CLI", 597 + }, 598 + subCommands: { 599 + // init command will be added in Task 8 600 + }, 601 + }); 602 + 603 + runMain(main); 604 + ``` 605 + 606 + **Step 4: Install dependencies** 607 + 608 + Run: `pnpm install` 609 + 610 + **Step 5: Verify build** 611 + 612 + Run: `pnpm --filter @atbb/cli build` 613 + Expected: Clean build. `packages/cli/dist/index.js` exists. 614 + 615 + **Step 6: Test that the CLI runs** 616 + 617 + Run: `node packages/cli/dist/index.js --help` 618 + Expected: Shows help text with "atBB Forum management CLI". 619 + 620 + **Step 7: Commit** 621 + 622 + ```bash 623 + git add packages/cli/ pnpm-lock.yaml 624 + git commit -m "chore: scaffold @atbb/cli package with citty" 625 + ``` 626 + 627 + --- 628 + 629 + ## Task 6: Implement CLI config loader and preflight checks 630 + 631 + **Files:** 632 + - Create: `packages/cli/src/lib/config.ts` 633 + - Create: `packages/cli/src/lib/preflight.ts` 634 + - Create: `packages/cli/src/__tests__/config.test.ts` 635 + - Create: `packages/cli/src/__tests__/preflight.test.ts` 636 + 637 + **Step 1: Write config tests** 638 + 639 + Create `packages/cli/src/__tests__/config.test.ts`: 640 + 641 + ```typescript 642 + import { describe, it, expect, vi, beforeEach } from "vitest"; 643 + import { loadCliConfig, type CliConfig } from "../lib/config.js"; 644 + 645 + describe("loadCliConfig", () => { 646 + beforeEach(() => { 647 + vi.unstubAllEnvs(); 648 + }); 649 + 650 + it("loads all required env vars", () => { 651 + vi.stubEnv("DATABASE_URL", "postgres://localhost:5432/atbb"); 652 + vi.stubEnv("FORUM_DID", "did:plc:test123"); 653 + vi.stubEnv("PDS_URL", "https://bsky.social"); 654 + vi.stubEnv("FORUM_HANDLE", "forum.example.com"); 655 + vi.stubEnv("FORUM_PASSWORD", "secret"); 656 + 657 + const config = loadCliConfig(); 658 + 659 + expect(config.databaseUrl).toBe("postgres://localhost:5432/atbb"); 660 + expect(config.forumDid).toBe("did:plc:test123"); 661 + expect(config.pdsUrl).toBe("https://bsky.social"); 662 + expect(config.forumHandle).toBe("forum.example.com"); 663 + expect(config.forumPassword).toBe("secret"); 664 + }); 665 + 666 + it("returns missing fields list when env vars are absent", () => { 667 + // No env vars set 668 + const config = loadCliConfig(); 669 + 670 + expect(config.missing).toContain("DATABASE_URL"); 671 + expect(config.missing).toContain("FORUM_DID"); 672 + expect(config.missing).toContain("FORUM_HANDLE"); 673 + expect(config.missing).toContain("FORUM_PASSWORD"); 674 + }); 675 + 676 + it("defaults PDS_URL to https://bsky.social", () => { 677 + vi.stubEnv("DATABASE_URL", "postgres://localhost/atbb"); 678 + vi.stubEnv("FORUM_DID", "did:plc:test"); 679 + vi.stubEnv("FORUM_HANDLE", "handle"); 680 + vi.stubEnv("FORUM_PASSWORD", "pass"); 681 + 682 + const config = loadCliConfig(); 683 + 684 + expect(config.pdsUrl).toBe("https://bsky.social"); 685 + expect(config.missing).toHaveLength(0); 686 + }); 687 + }); 688 + ``` 689 + 690 + **Step 2: Write preflight tests** 691 + 692 + Create `packages/cli/src/__tests__/preflight.test.ts`: 693 + 694 + ```typescript 695 + import { describe, it, expect, vi } from "vitest"; 696 + import { checkEnvironment } from "../lib/preflight.js"; 697 + import type { CliConfig } from "../lib/config.js"; 698 + 699 + describe("checkEnvironment", () => { 700 + it("returns success when all required vars are present", () => { 701 + const config: CliConfig = { 702 + databaseUrl: "postgres://localhost/atbb", 703 + forumDid: "did:plc:test", 704 + pdsUrl: "https://bsky.social", 705 + forumHandle: "forum.example.com", 706 + forumPassword: "secret", 707 + missing: [], 708 + }; 709 + 710 + const result = checkEnvironment(config); 711 + 712 + expect(result.ok).toBe(true); 713 + expect(result.errors).toHaveLength(0); 714 + }); 715 + 716 + it("returns errors when required vars are missing", () => { 717 + const config: CliConfig = { 718 + databaseUrl: "", 719 + forumDid: "", 720 + pdsUrl: "https://bsky.social", 721 + forumHandle: "", 722 + forumPassword: "", 723 + missing: ["DATABASE_URL", "FORUM_DID", "FORUM_HANDLE", "FORUM_PASSWORD"], 724 + }; 725 + 726 + const result = checkEnvironment(config); 727 + 728 + expect(result.ok).toBe(false); 729 + expect(result.errors).toContain("DATABASE_URL"); 730 + expect(result.errors).toContain("FORUM_DID"); 731 + expect(result.errors).toContain("FORUM_HANDLE"); 732 + expect(result.errors).toContain("FORUM_PASSWORD"); 733 + }); 734 + }); 735 + ``` 736 + 737 + **Step 3: Run tests to verify they fail** 738 + 739 + Run: `pnpm --filter @atbb/cli test` 740 + Expected: FAIL — modules don't exist yet. 741 + 742 + **Step 4: Implement config.ts** 743 + 744 + Create `packages/cli/src/lib/config.ts`: 745 + 746 + ```typescript 747 + export interface CliConfig { 748 + databaseUrl: string; 749 + forumDid: string; 750 + pdsUrl: string; 751 + forumHandle: string; 752 + forumPassword: string; 753 + missing: string[]; 754 + } 755 + 756 + /** 757 + * Load CLI configuration from environment variables. 758 + * Returns a config object with a `missing` array listing absent required vars. 759 + */ 760 + export function loadCliConfig(): CliConfig { 761 + const missing: string[] = []; 762 + 763 + const databaseUrl = process.env.DATABASE_URL ?? ""; 764 + const forumDid = process.env.FORUM_DID ?? ""; 765 + const pdsUrl = process.env.PDS_URL ?? "https://bsky.social"; 766 + const forumHandle = process.env.FORUM_HANDLE ?? ""; 767 + const forumPassword = process.env.FORUM_PASSWORD ?? ""; 768 + 769 + if (!databaseUrl) missing.push("DATABASE_URL"); 770 + if (!forumDid) missing.push("FORUM_DID"); 771 + if (!forumHandle) missing.push("FORUM_HANDLE"); 772 + if (!forumPassword) missing.push("FORUM_PASSWORD"); 773 + 774 + return { databaseUrl, forumDid, pdsUrl, forumHandle, forumPassword, missing }; 775 + } 776 + ``` 777 + 778 + **Step 5: Implement preflight.ts** 779 + 780 + Create `packages/cli/src/lib/preflight.ts`: 781 + 782 + ```typescript 783 + import type { CliConfig } from "./config.js"; 784 + 785 + export interface PreflightResult { 786 + ok: boolean; 787 + errors: string[]; 788 + } 789 + 790 + /** 791 + * Check that all required environment variables are present. 792 + */ 793 + export function checkEnvironment(config: CliConfig): PreflightResult { 794 + if (config.missing.length === 0) { 795 + return { ok: true, errors: [] }; 796 + } 797 + return { ok: false, errors: config.missing }; 798 + } 799 + ``` 800 + 801 + **Step 6: Run tests to verify they pass** 802 + 803 + Run: `pnpm --filter @atbb/cli test` 804 + Expected: All pass. 805 + 806 + **Step 7: Commit** 807 + 808 + ```bash 809 + git add packages/cli/src/lib/ packages/cli/src/__tests__/ 810 + git commit -m "feat(cli): add config loader and preflight environment checks" 811 + ``` 812 + 813 + --- 814 + 815 + ## Task 7: Implement create-forum step 816 + 817 + **Files:** 818 + - Create: `packages/cli/src/lib/steps/create-forum.ts` 819 + - Create: `packages/cli/src/__tests__/create-forum.test.ts` 820 + 821 + **Step 1: Write the failing tests** 822 + 823 + Create `packages/cli/src/__tests__/create-forum.test.ts`: 824 + 825 + ```typescript 826 + import { describe, it, expect, vi } from "vitest"; 827 + import { createForumRecord } from "../lib/steps/create-forum.js"; 828 + 829 + describe("createForumRecord", () => { 830 + const forumDid = "did:plc:testforum"; 831 + 832 + function mockAgent(overrides: Record<string, any> = {}) { 833 + return { 834 + com: { 835 + atproto: { 836 + repo: { 837 + getRecord: vi.fn().mockRejectedValue({ status: 400 }), 838 + createRecord: vi.fn().mockResolvedValue({ 839 + data: { uri: `at://${forumDid}/space.atbb.forum.forum/self`, cid: "bafytest" }, 840 + }), 841 + ...overrides, 842 + }, 843 + }, 844 + }, 845 + } as any; 846 + } 847 + 848 + it("creates forum record when it does not exist", async () => { 849 + const agent = mockAgent(); 850 + 851 + const result = await createForumRecord(agent, forumDid, { 852 + name: "My Forum", 853 + description: "A test forum", 854 + }); 855 + 856 + expect(result.created).toBe(true); 857 + expect(result.uri).toContain("space.atbb.forum.forum/self"); 858 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 859 + expect.objectContaining({ 860 + repo: forumDid, 861 + collection: "space.atbb.forum.forum", 862 + rkey: "self", 863 + record: expect.objectContaining({ 864 + $type: "space.atbb.forum.forum", 865 + name: "My Forum", 866 + description: "A test forum", 867 + }), 868 + }) 869 + ); 870 + }); 871 + 872 + it("skips creation when forum record already exists", async () => { 873 + const agent = mockAgent({ 874 + getRecord: vi.fn().mockResolvedValue({ 875 + data: { 876 + uri: `at://${forumDid}/space.atbb.forum.forum/self`, 877 + cid: "bafyexisting", 878 + value: { name: "Existing Forum" }, 879 + }, 880 + }), 881 + }); 882 + 883 + const result = await createForumRecord(agent, forumDid, { 884 + name: "My Forum", 885 + }); 886 + 887 + expect(result.created).toBe(false); 888 + expect(result.skipped).toBe(true); 889 + expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); 890 + }); 891 + 892 + it("throws when PDS write fails", async () => { 893 + const agent = mockAgent({ 894 + createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")), 895 + }); 896 + 897 + await expect( 898 + createForumRecord(agent, forumDid, { name: "My Forum" }) 899 + ).rejects.toThrow("PDS write failed"); 900 + }); 901 + }); 902 + ``` 903 + 904 + **Step 2: Run tests to verify they fail** 905 + 906 + Run: `pnpm --filter @atbb/cli test` 907 + Expected: FAIL — module doesn't exist. 908 + 909 + **Step 3: Implement create-forum.ts** 910 + 911 + Create `packages/cli/src/lib/steps/create-forum.ts`: 912 + 913 + ```typescript 914 + import type { AtpAgent } from "@atproto/api"; 915 + 916 + interface CreateForumInput { 917 + name: string; 918 + description?: string; 919 + } 920 + 921 + interface CreateForumResult { 922 + created: boolean; 923 + skipped: boolean; 924 + uri?: string; 925 + existingName?: string; 926 + } 927 + 928 + /** 929 + * Create the space.atbb.forum.forum/self record on the Forum DID's PDS. 930 + * Idempotent: skips if the record already exists. 931 + */ 932 + export async function createForumRecord( 933 + agent: AtpAgent, 934 + forumDid: string, 935 + input: CreateForumInput 936 + ): Promise<CreateForumResult> { 937 + // Check if forum record already exists 938 + try { 939 + const existing = await agent.com.atproto.repo.getRecord({ 940 + repo: forumDid, 941 + collection: "space.atbb.forum.forum", 942 + rkey: "self", 943 + }); 944 + 945 + return { 946 + created: false, 947 + skipped: true, 948 + uri: existing.data.uri, 949 + existingName: (existing.data.value as any)?.name, 950 + }; 951 + } catch { 952 + // Record doesn't exist — continue to create it 953 + } 954 + 955 + const response = await agent.com.atproto.repo.createRecord({ 956 + repo: forumDid, 957 + collection: "space.atbb.forum.forum", 958 + rkey: "self", 959 + record: { 960 + $type: "space.atbb.forum.forum", 961 + name: input.name, 962 + ...(input.description && { description: input.description }), 963 + createdAt: new Date().toISOString(), 964 + }, 965 + }); 966 + 967 + return { 968 + created: true, 969 + skipped: false, 970 + uri: response.data.uri, 971 + }; 972 + } 973 + ``` 974 + 975 + **Step 4: Run tests to verify they pass** 976 + 977 + Run: `pnpm --filter @atbb/cli test` 978 + Expected: All pass. 979 + 980 + **Step 5: Commit** 981 + 982 + ```bash 983 + git add packages/cli/src/lib/steps/create-forum.ts packages/cli/src/__tests__/create-forum.test.ts 984 + git commit -m "feat(cli): implement create-forum bootstrap step" 985 + ``` 986 + 987 + --- 988 + 989 + ## Task 8: Implement seed-roles step 990 + 991 + **Files:** 992 + - Create: `packages/cli/src/lib/steps/seed-roles.ts` 993 + - Create: `packages/cli/src/__tests__/seed-roles.test.ts` 994 + 995 + **Step 1: Write the failing tests** 996 + 997 + Create `packages/cli/src/__tests__/seed-roles.test.ts`: 998 + 999 + ```typescript 1000 + import { describe, it, expect, vi } from "vitest"; 1001 + import { seedDefaultRoles, DEFAULT_ROLES } from "../lib/steps/seed-roles.js"; 1002 + 1003 + describe("seedDefaultRoles", () => { 1004 + const forumDid = "did:plc:testforum"; 1005 + 1006 + function mockDb(existingRoleNames: string[] = []) { 1007 + return { 1008 + select: vi.fn().mockReturnValue({ 1009 + from: vi.fn().mockReturnValue({ 1010 + where: vi.fn().mockReturnValue({ 1011 + limit: vi.fn().mockImplementation(() => { 1012 + // Return empty array for non-existing, populated for existing 1013 + const roleName = existingRoleNames.length > 0 ? existingRoleNames.shift() : undefined; 1014 + return roleName ? [{ name: roleName }] : []; 1015 + }), 1016 + }), 1017 + }), 1018 + }), 1019 + } as any; 1020 + } 1021 + 1022 + function mockAgent() { 1023 + return { 1024 + com: { 1025 + atproto: { 1026 + repo: { 1027 + createRecord: vi.fn().mockResolvedValue({ 1028 + data: { uri: `at://${forumDid}/space.atbb.forum.role/test`, cid: "bafytest" }, 1029 + }), 1030 + }, 1031 + }, 1032 + }, 1033 + } as any; 1034 + } 1035 + 1036 + it("exports DEFAULT_ROLES with correct structure", () => { 1037 + expect(DEFAULT_ROLES).toHaveLength(4); 1038 + expect(DEFAULT_ROLES[0].name).toBe("Owner"); 1039 + expect(DEFAULT_ROLES[0].priority).toBe(0); 1040 + expect(DEFAULT_ROLES[3].name).toBe("Member"); 1041 + expect(DEFAULT_ROLES[3].priority).toBe(30); 1042 + }); 1043 + 1044 + it("creates all roles when none exist", async () => { 1045 + const db = mockDb(); 1046 + const agent = mockAgent(); 1047 + 1048 + const result = await seedDefaultRoles(db, agent, forumDid); 1049 + 1050 + expect(result.created).toBe(4); 1051 + expect(result.skipped).toBe(0); 1052 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(4); 1053 + }); 1054 + 1055 + it("skips existing roles", async () => { 1056 + // Simulate Owner and Admin already existing 1057 + const db = mockDb(["Owner", "Admin"]); 1058 + const agent = mockAgent(); 1059 + 1060 + const result = await seedDefaultRoles(db, agent, forumDid); 1061 + 1062 + expect(result.skipped).toBe(2); 1063 + expect(result.created).toBe(2); 1064 + }); 1065 + }); 1066 + ``` 1067 + 1068 + **Step 2: Run tests to verify they fail** 1069 + 1070 + Run: `pnpm --filter @atbb/cli test` 1071 + Expected: FAIL. 1072 + 1073 + **Step 3: Implement seed-roles.ts** 1074 + 1075 + Create `packages/cli/src/lib/steps/seed-roles.ts`: 1076 + 1077 + ```typescript 1078 + import type { AtpAgent } from "@atproto/api"; 1079 + import type { Database } from "@atbb/db"; 1080 + import { roles } from "@atbb/db"; 1081 + import { eq } from "drizzle-orm"; 1082 + 1083 + interface DefaultRole { 1084 + name: string; 1085 + description: string; 1086 + permissions: string[]; 1087 + priority: number; 1088 + } 1089 + 1090 + export const DEFAULT_ROLES: DefaultRole[] = [ 1091 + { 1092 + name: "Owner", 1093 + description: "Forum owner with full control", 1094 + permissions: ["*"], 1095 + priority: 0, 1096 + }, 1097 + { 1098 + name: "Admin", 1099 + description: "Can manage forum structure and users", 1100 + permissions: [ 1101 + "space.atbb.permission.manageCategories", 1102 + "space.atbb.permission.manageRoles", 1103 + "space.atbb.permission.manageMembers", 1104 + "space.atbb.permission.moderatePosts", 1105 + "space.atbb.permission.banUsers", 1106 + "space.atbb.permission.pinTopics", 1107 + "space.atbb.permission.lockTopics", 1108 + "space.atbb.permission.createTopics", 1109 + "space.atbb.permission.createPosts", 1110 + ], 1111 + priority: 10, 1112 + }, 1113 + { 1114 + name: "Moderator", 1115 + description: "Can moderate content and users", 1116 + permissions: [ 1117 + "space.atbb.permission.moderatePosts", 1118 + "space.atbb.permission.banUsers", 1119 + "space.atbb.permission.pinTopics", 1120 + "space.atbb.permission.lockTopics", 1121 + "space.atbb.permission.createTopics", 1122 + "space.atbb.permission.createPosts", 1123 + ], 1124 + priority: 20, 1125 + }, 1126 + { 1127 + name: "Member", 1128 + description: "Regular forum member", 1129 + permissions: [ 1130 + "space.atbb.permission.createTopics", 1131 + "space.atbb.permission.createPosts", 1132 + ], 1133 + priority: 30, 1134 + }, 1135 + ]; 1136 + 1137 + interface SeedRolesResult { 1138 + created: number; 1139 + skipped: number; 1140 + } 1141 + 1142 + /** 1143 + * Seed default roles to Forum DID's PDS. 1144 + * Idempotent: checks for existing roles by name before creating. 1145 + */ 1146 + export async function seedDefaultRoles( 1147 + db: Database, 1148 + agent: AtpAgent, 1149 + forumDid: string 1150 + ): Promise<SeedRolesResult> { 1151 + let created = 0; 1152 + let skipped = 0; 1153 + 1154 + for (const defaultRole of DEFAULT_ROLES) { 1155 + // Check if role already exists by name 1156 + const [existingRole] = await db 1157 + .select() 1158 + .from(roles) 1159 + .where(eq(roles.name, defaultRole.name)) 1160 + .limit(1); 1161 + 1162 + if (existingRole) { 1163 + skipped++; 1164 + continue; 1165 + } 1166 + 1167 + // Create role record on Forum DID's PDS 1168 + await agent.com.atproto.repo.createRecord({ 1169 + repo: forumDid, 1170 + collection: "space.atbb.forum.role", 1171 + record: { 1172 + $type: "space.atbb.forum.role", 1173 + name: defaultRole.name, 1174 + description: defaultRole.description, 1175 + permissions: defaultRole.permissions, 1176 + priority: defaultRole.priority, 1177 + createdAt: new Date().toISOString(), 1178 + }, 1179 + }); 1180 + 1181 + created++; 1182 + } 1183 + 1184 + return { created, skipped }; 1185 + } 1186 + ``` 1187 + 1188 + **Step 4: Run tests to verify they pass** 1189 + 1190 + Run: `pnpm --filter @atbb/cli test` 1191 + Expected: All pass. 1192 + 1193 + **Step 5: Commit** 1194 + 1195 + ```bash 1196 + git add packages/cli/src/lib/steps/seed-roles.ts packages/cli/src/__tests__/seed-roles.test.ts 1197 + git commit -m "feat(cli): implement seed-roles bootstrap step" 1198 + ``` 1199 + 1200 + --- 1201 + 1202 + ## Task 9: Implement assign-owner step 1203 + 1204 + **Files:** 1205 + - Create: `packages/cli/src/lib/steps/assign-owner.ts` 1206 + - Create: `packages/cli/src/__tests__/assign-owner.test.ts` 1207 + 1208 + **Step 1: Write the failing tests** 1209 + 1210 + Create `packages/cli/src/__tests__/assign-owner.test.ts`: 1211 + 1212 + ```typescript 1213 + import { describe, it, expect, vi } from "vitest"; 1214 + import { assignOwnerRole } from "../lib/steps/assign-owner.js"; 1215 + 1216 + describe("assignOwnerRole", () => { 1217 + const forumDid = "did:plc:testforum"; 1218 + const ownerDid = "did:plc:owner123"; 1219 + 1220 + function mockDb(options: { ownerRole?: any; existingMembership?: any } = {}) { 1221 + const selectMock = vi.fn(); 1222 + 1223 + // First call: find Owner role 1224 + // Second call: find existing membership 1225 + let callCount = 0; 1226 + selectMock.mockImplementation(() => ({ 1227 + from: vi.fn().mockReturnValue({ 1228 + where: vi.fn().mockReturnValue({ 1229 + limit: vi.fn().mockImplementation(() => { 1230 + callCount++; 1231 + if (callCount === 1) { 1232 + return options.ownerRole ? [options.ownerRole] : []; 1233 + } 1234 + return options.existingMembership ? [options.existingMembership] : []; 1235 + }), 1236 + }), 1237 + }), 1238 + })); 1239 + 1240 + return { select: selectMock } as any; 1241 + } 1242 + 1243 + function mockAgent() { 1244 + return { 1245 + com: { 1246 + atproto: { 1247 + repo: { 1248 + createRecord: vi.fn().mockResolvedValue({ 1249 + data: { uri: `at://${forumDid}/space.atbb.membership/owner`, cid: "bafytest" }, 1250 + }), 1251 + }, 1252 + }, 1253 + }, 1254 + } as any; 1255 + } 1256 + 1257 + const ownerRole = { 1258 + id: 1n, 1259 + did: forumDid, 1260 + rkey: "owner", 1261 + cid: "bafyrole", 1262 + name: "Owner", 1263 + priority: 0, 1264 + }; 1265 + 1266 + it("assigns owner role when user has no existing role", async () => { 1267 + const db = mockDb({ ownerRole }); 1268 + const agent = mockAgent(); 1269 + 1270 + const result = await assignOwnerRole(db, agent, forumDid, ownerDid); 1271 + 1272 + expect(result.assigned).toBe(true); 1273 + expect(result.skipped).toBe(false); 1274 + }); 1275 + 1276 + it("skips when user already has Owner role", async () => { 1277 + const db = mockDb({ 1278 + ownerRole, 1279 + existingMembership: { did: ownerDid, roleUri: `at://${forumDid}/space.atbb.forum.role/owner` }, 1280 + }); 1281 + const agent = mockAgent(); 1282 + 1283 + const result = await assignOwnerRole(db, agent, forumDid, ownerDid); 1284 + 1285 + expect(result.assigned).toBe(false); 1286 + expect(result.skipped).toBe(true); 1287 + expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); 1288 + }); 1289 + 1290 + it("throws when Owner role is not found in database", async () => { 1291 + const db = mockDb({ ownerRole: null }); 1292 + const agent = mockAgent(); 1293 + 1294 + await expect( 1295 + assignOwnerRole(db, agent, forumDid, ownerDid) 1296 + ).rejects.toThrow("Owner role not found"); 1297 + }); 1298 + }); 1299 + ``` 1300 + 1301 + **Step 2: Run tests to verify they fail** 1302 + 1303 + Run: `pnpm --filter @atbb/cli test` 1304 + Expected: FAIL. 1305 + 1306 + **Step 3: Implement assign-owner.ts** 1307 + 1308 + Create `packages/cli/src/lib/steps/assign-owner.ts`: 1309 + 1310 + ```typescript 1311 + import type { AtpAgent } from "@atproto/api"; 1312 + import type { Database } from "@atbb/db"; 1313 + import { roles, memberships } from "@atbb/db"; 1314 + import { eq, and } from "drizzle-orm"; 1315 + 1316 + interface AssignOwnerResult { 1317 + assigned: boolean; 1318 + skipped: boolean; 1319 + roleUri?: string; 1320 + } 1321 + 1322 + /** 1323 + * Assign the Owner role to a user. 1324 + * Idempotent: skips if the user already has the Owner role. 1325 + * 1326 + * This writes a membership record on the Forum DID's PDS that links 1327 + * the owner's DID to the Owner role. The firehose indexer will pick 1328 + * this up and populate the database. 1329 + */ 1330 + export async function assignOwnerRole( 1331 + db: Database, 1332 + agent: AtpAgent, 1333 + forumDid: string, 1334 + ownerDid: string 1335 + ): Promise<AssignOwnerResult> { 1336 + // Find the Owner role in the database 1337 + const [ownerRole] = await db 1338 + .select() 1339 + .from(roles) 1340 + .where(eq(roles.name, "Owner")) 1341 + .limit(1); 1342 + 1343 + if (!ownerRole) { 1344 + throw new Error( 1345 + "Owner role not found in database. Run role seeding first." 1346 + ); 1347 + } 1348 + 1349 + const roleUri = `at://${ownerRole.did}/space.atbb.forum.role/${ownerRole.rkey}`; 1350 + 1351 + // Check if user already has a membership with this role 1352 + const [existingMembership] = await db 1353 + .select() 1354 + .from(memberships) 1355 + .where(and(eq(memberships.did, ownerDid), eq(memberships.roleUri, roleUri))) 1356 + .limit(1); 1357 + 1358 + if (existingMembership) { 1359 + return { assigned: false, skipped: true, roleUri }; 1360 + } 1361 + 1362 + // Write membership record assigning the Owner role 1363 + // This is written on the forum DID's repo (not the user's) 1364 + // because the CLI has the forum credentials, not the user's credentials. 1365 + await agent.com.atproto.repo.createRecord({ 1366 + repo: forumDid, 1367 + collection: "space.atbb.membership", 1368 + record: { 1369 + $type: "space.atbb.membership", 1370 + did: ownerDid, 1371 + forum: { 1372 + uri: `at://${forumDid}/space.atbb.forum.forum/self`, 1373 + cid: "pending", // Will be updated by indexer 1374 + }, 1375 + role: { 1376 + uri: roleUri, 1377 + cid: ownerRole.cid, 1378 + }, 1379 + joinedAt: new Date().toISOString(), 1380 + createdAt: new Date().toISOString(), 1381 + }, 1382 + }); 1383 + 1384 + return { assigned: true, skipped: false, roleUri }; 1385 + } 1386 + ``` 1387 + 1388 + **Step 4: Run tests to verify they pass** 1389 + 1390 + Run: `pnpm --filter @atbb/cli test` 1391 + Expected: All pass. 1392 + 1393 + **Step 5: Commit** 1394 + 1395 + ```bash 1396 + git add packages/cli/src/lib/steps/assign-owner.ts packages/cli/src/__tests__/assign-owner.test.ts 1397 + git commit -m "feat(cli): implement assign-owner bootstrap step" 1398 + ``` 1399 + 1400 + --- 1401 + 1402 + ## Task 10: Wire up the `init` command 1403 + 1404 + **Files:** 1405 + - Create: `packages/cli/src/commands/init.ts` 1406 + - Modify: `packages/cli/src/index.ts` 1407 + - Modify: `packages/cli/package.json` (move `@inquirer/prompts` to dependencies) 1408 + 1409 + **Step 1: Move `@inquirer/prompts` to dependencies** 1410 + 1411 + In `packages/cli/package.json`, move `@inquirer/prompts` from `devDependencies` to `dependencies`: 1412 + 1413 + ```json 1414 + "dependencies": { 1415 + "@atbb/atproto": "workspace:*", 1416 + "@atbb/db": "workspace:*", 1417 + "@atproto/api": "^0.15.0", 1418 + "@inquirer/prompts": "^7.0.0", 1419 + "citty": "^0.1.6", 1420 + "consola": "^3.4.0" 1421 + } 1422 + ``` 1423 + 1424 + Run: `pnpm install` 1425 + 1426 + **Step 2: Implement the init command** 1427 + 1428 + Create `packages/cli/src/commands/init.ts`: 1429 + 1430 + ```typescript 1431 + import { defineCommand } from "citty"; 1432 + import consola from "consola"; 1433 + import { input } from "@inquirer/prompts"; 1434 + import { createDb } from "@atbb/db"; 1435 + import { ForumAgent, resolveIdentity } from "@atbb/atproto"; 1436 + import { loadCliConfig } from "../lib/config.js"; 1437 + import { checkEnvironment } from "../lib/preflight.js"; 1438 + import { createForumRecord } from "../lib/steps/create-forum.js"; 1439 + import { seedDefaultRoles } from "../lib/steps/seed-roles.js"; 1440 + import { assignOwnerRole } from "../lib/steps/assign-owner.js"; 1441 + 1442 + export const initCommand = defineCommand({ 1443 + meta: { 1444 + name: "init", 1445 + description: "Bootstrap a new atBB forum instance", 1446 + }, 1447 + args: { 1448 + "forum-name": { 1449 + type: "string", 1450 + description: "Forum name", 1451 + }, 1452 + "forum-description": { 1453 + type: "string", 1454 + description: "Forum description", 1455 + }, 1456 + owner: { 1457 + type: "string", 1458 + description: "Owner handle or DID (e.g., alice.bsky.social or did:plc:abc123)", 1459 + }, 1460 + }, 1461 + async run({ args }) { 1462 + consola.box("atBB Forum Setup"); 1463 + 1464 + // Step 0: Preflight checks 1465 + consola.start("Checking environment..."); 1466 + const config = loadCliConfig(); 1467 + const envCheck = checkEnvironment(config); 1468 + 1469 + if (!envCheck.ok) { 1470 + consola.error("Missing required environment variables:"); 1471 + for (const name of envCheck.errors) { 1472 + consola.error(` - ${name}`); 1473 + } 1474 + consola.info("Set these in your .env file or environment, then re-run."); 1475 + process.exit(1); 1476 + } 1477 + 1478 + consola.success(`DATABASE_URL configured`); 1479 + consola.success(`FORUM_DID: ${config.forumDid}`); 1480 + consola.success(`PDS_URL: ${config.pdsUrl}`); 1481 + consola.success(`FORUM_HANDLE / FORUM_PASSWORD configured`); 1482 + 1483 + // Step 1: Connect to database 1484 + consola.start("Connecting to database..."); 1485 + let db; 1486 + try { 1487 + db = createDb(config.databaseUrl); 1488 + // Quick connectivity check 1489 + await db.execute("SELECT 1"); 1490 + consola.success("Database connection successful"); 1491 + } catch (error) { 1492 + consola.error("Failed to connect to database:", error instanceof Error ? error.message : String(error)); 1493 + consola.info("Check your DATABASE_URL and ensure the database is running."); 1494 + process.exit(1); 1495 + } 1496 + 1497 + // Step 2: Authenticate as Forum DID 1498 + consola.start("Authenticating as Forum DID..."); 1499 + const forumAgent = new ForumAgent(config.pdsUrl, config.forumHandle, config.forumPassword); 1500 + await forumAgent.initialize(); 1501 + 1502 + if (!forumAgent.isAuthenticated()) { 1503 + const status = forumAgent.getStatus(); 1504 + consola.error(`Failed to authenticate: ${status.error}`); 1505 + if (status.status === "failed") { 1506 + consola.info("Check your FORUM_HANDLE and FORUM_PASSWORD."); 1507 + } 1508 + await forumAgent.shutdown(); 1509 + process.exit(1); 1510 + } 1511 + 1512 + const agent = forumAgent.getAgent()!; 1513 + consola.success(`Authenticated as ${config.forumHandle}`); 1514 + 1515 + // Step 3: Create forum record 1516 + consola.log(""); 1517 + consola.info("Step 1: Create Forum Record"); 1518 + 1519 + const forumName = args["forum-name"] ?? await input({ 1520 + message: "Forum name:", 1521 + default: "My Forum", 1522 + }); 1523 + 1524 + const forumDescription = args["forum-description"] ?? await input({ 1525 + message: "Forum description (optional):", 1526 + }); 1527 + 1528 + try { 1529 + const forumResult = await createForumRecord(agent, config.forumDid, { 1530 + name: forumName, 1531 + ...(forumDescription && { description: forumDescription }), 1532 + }); 1533 + 1534 + if (forumResult.skipped) { 1535 + consola.warn(`Forum record already exists: "${forumResult.existingName}"`); 1536 + } else { 1537 + consola.success(`Created forum record: ${forumResult.uri}`); 1538 + } 1539 + } catch (error) { 1540 + consola.error("Failed to create forum record:", error instanceof Error ? error.message : String(error)); 1541 + await forumAgent.shutdown(); 1542 + process.exit(1); 1543 + } 1544 + 1545 + // Step 4: Seed default roles 1546 + consola.log(""); 1547 + consola.info("Step 2: Seed Default Roles"); 1548 + 1549 + try { 1550 + const rolesResult = await seedDefaultRoles(db, agent, config.forumDid); 1551 + if (rolesResult.created > 0) { 1552 + consola.success(`Created ${rolesResult.created} role(s)`); 1553 + } 1554 + if (rolesResult.skipped > 0) { 1555 + consola.warn(`Skipped ${rolesResult.skipped} existing role(s)`); 1556 + } 1557 + } catch (error) { 1558 + consola.error("Failed to seed roles:", error instanceof Error ? error.message : String(error)); 1559 + await forumAgent.shutdown(); 1560 + process.exit(1); 1561 + } 1562 + 1563 + // Step 5: Assign owner 1564 + consola.log(""); 1565 + consola.info("Step 3: Assign Forum Owner"); 1566 + 1567 + const ownerInput = args.owner ?? await input({ 1568 + message: "Owner handle or DID:", 1569 + }); 1570 + 1571 + try { 1572 + consola.start("Resolving identity..."); 1573 + const identity = await resolveIdentity(ownerInput, config.pdsUrl); 1574 + 1575 + if (identity.handle) { 1576 + consola.success(`Resolved ${identity.handle} to ${identity.did}`); 1577 + } 1578 + 1579 + const ownerResult = await assignOwnerRole(db, agent, config.forumDid, identity.did); 1580 + 1581 + if (ownerResult.skipped) { 1582 + consola.warn(`${ownerInput} already has the Owner role`); 1583 + } else { 1584 + consola.success(`Assigned Owner role to ${ownerInput}`); 1585 + } 1586 + } catch (error) { 1587 + consola.error("Failed to assign owner:", error instanceof Error ? error.message : String(error)); 1588 + await forumAgent.shutdown(); 1589 + process.exit(1); 1590 + } 1591 + 1592 + // Done! 1593 + await forumAgent.shutdown(); 1594 + 1595 + consola.log(""); 1596 + consola.box({ 1597 + title: "Forum bootstrap complete!", 1598 + message: [ 1599 + "Next steps:", 1600 + " 1. Start the appview: pnpm --filter @atbb/appview dev", 1601 + " 2. Start the web UI: pnpm --filter @atbb/web dev", 1602 + ` 3. Log in as ${ownerInput} to access admin features`, 1603 + " 4. Create categories and boards from the admin panel", 1604 + ].join("\n"), 1605 + }); 1606 + }, 1607 + }); 1608 + ``` 1609 + 1610 + **Step 3: Update CLI entrypoint** 1611 + 1612 + Replace `packages/cli/src/index.ts`: 1613 + 1614 + ```typescript 1615 + #!/usr/bin/env node 1616 + import { defineCommand, runMain } from "citty"; 1617 + import { initCommand } from "./commands/init.js"; 1618 + 1619 + const main = defineCommand({ 1620 + meta: { 1621 + name: "atbb", 1622 + version: "0.1.0", 1623 + description: "atBB Forum management CLI", 1624 + }, 1625 + subCommands: { 1626 + init: initCommand, 1627 + }, 1628 + }); 1629 + 1630 + runMain(main); 1631 + ``` 1632 + 1633 + **Step 4: Verify build** 1634 + 1635 + Run: `pnpm --filter @atbb/cli build` 1636 + Expected: Clean build. 1637 + 1638 + **Step 5: Verify help output** 1639 + 1640 + Run: `node packages/cli/dist/index.js init --help` 1641 + Expected: Shows init command help with `--forum-name`, `--forum-description`, `--owner` args. 1642 + 1643 + **Step 6: Commit** 1644 + 1645 + ```bash 1646 + git add packages/cli/ 1647 + git commit -m "feat(cli): wire up init command with interactive prompts and flag overrides" 1648 + ``` 1649 + 1650 + --- 1651 + 1652 + ## Task 11: Update Dockerfile and turbo config 1653 + 1654 + **Files:** 1655 + - Modify: `Dockerfile` 1656 + - Modify: `turbo.json` (add env vars for CLI if needed) 1657 + 1658 + **Step 1: Update Dockerfile builder stage** 1659 + 1660 + In the builder stage, add the new packages to the COPY commands. After the existing package.json COPY lines, add: 1661 + 1662 + ```dockerfile 1663 + COPY packages/atproto/package.json ./packages/atproto/ 1664 + COPY packages/cli/package.json ./packages/cli/ 1665 + ``` 1666 + 1667 + **Step 2: Update Dockerfile runtime stage** 1668 + 1669 + In the runtime stage, add: 1670 + 1671 + ```dockerfile 1672 + # Copy package files for production install 1673 + COPY packages/atproto/package.json ./packages/atproto/ 1674 + COPY packages/cli/package.json ./packages/cli/ 1675 + 1676 + # Copy built artifacts from builder stage (add these after existing COPY --from=builder lines) 1677 + COPY --from=builder /build/packages/atproto/dist ./packages/atproto/dist 1678 + COPY --from=builder /build/packages/cli/dist ./packages/cli/dist 1679 + ``` 1680 + 1681 + **Step 3: Verify Docker build** 1682 + 1683 + Run: `docker build -t atbb:test .` 1684 + Expected: Build succeeds. 1685 + 1686 + If Docker is not available locally, verify by checking the Dockerfile is syntactically correct and commit — CI will catch Docker build issues. 1687 + 1688 + **Step 4: Commit** 1689 + 1690 + ```bash 1691 + git add Dockerfile 1692 + git commit -m "build: add @atbb/atproto and @atbb/cli to Docker image" 1693 + ``` 1694 + 1695 + --- 1696 + 1697 + ## Task 12: Full integration test and final verification 1698 + 1699 + **Step 1: Run the full build** 1700 + 1701 + Run: `pnpm build` 1702 + Expected: All packages build successfully. Turbo handles dependency ordering. 1703 + 1704 + **Step 2: Run all tests** 1705 + 1706 + Run: `pnpm test` 1707 + Expected: All tests pass across all packages. 1708 + 1709 + **Step 3: Run lint** 1710 + 1711 + Run: `pnpm lint` 1712 + Expected: No type errors. 1713 + 1714 + **Step 4: Verify CLI end-to-end (dry run)** 1715 + 1716 + Run: `node packages/cli/dist/index.js init --help` 1717 + Expected: Shows help text. 1718 + 1719 + Run: `node packages/cli/dist/index.js init` (without .env, expect graceful failure) 1720 + Expected: "Missing required environment variables" error with list. 1721 + 1722 + **Step 5: Final commit if any cleanup needed** 1723 + 1724 + If any adjustments were made during verification: 1725 + 1726 + ```bash 1727 + git add -A 1728 + git commit -m "chore: cleanup after integration verification" 1729 + ```