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
at claude/bold-mclean 1729 lines 47 kB view raw view rendered
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 77Run: `pnpm install` 78 79**Step 5: Verify build** 80 81Run: `pnpm --filter @atbb/atproto build` 82Expected: Clean build, `packages/atproto/dist/index.js` exists. 83 84**Step 6: Commit** 85 86```bash 87git add packages/atproto/ 88git 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 109Create `packages/atproto/src/__tests__/errors.test.ts`: 110 111```typescript 112import { describe, it, expect } from "vitest"; 113import { isProgrammingError, isNetworkError, isAuthError, isDatabaseError } from "../errors.js"; 114 115describe("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 138describe("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 160describe("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 182describe("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 199Run: `pnpm --filter @atbb/atproto test` 200Expected: FAIL — `errors.js` module does not exist yet. 201 202**Step 3: Create `packages/atproto/src/errors.ts`** 203 204Copy 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 */ 211export 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 */ 223export 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 */ 242export 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 */ 257export 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 273export { 274 isProgrammingError, 275 isNetworkError, 276 isAuthError, 277 isDatabaseError, 278} from "./errors.js"; 279``` 280 281**Step 5: Run tests to verify they pass** 282 283Run: `pnpm --filter @atbb/atproto test` 284Expected: All PASS. 285 286**Step 6: Update appview to import from `@atbb/atproto`** 287 288Add `@atbb/atproto` dependency to `apps/appview/package.json`: 289 290```json 291"@atbb/atproto": "workspace:*" 292``` 293 294Then run: `pnpm install` 295 296Replace `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. 301export { 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 311Run: `pnpm build && pnpm --filter @atbb/appview test` 312Expected: All pass — no behavioral changes. 313 314**Step 8: Commit** 315 316```bash 317git add packages/atproto/ apps/appview/package.json apps/appview/src/lib/errors.ts pnpm-lock.yaml 318git 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 335Copy `apps/appview/src/lib/forum-agent.ts``packages/atproto/src/forum-agent.ts`. 336 337The only change: replace the local `isAuthError` / `isNetworkError` helper functions at the top of the file with an import: 338 339```typescript 340import { isAuthError, isNetworkError } from "./errors.js"; 341``` 342 343Remove 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 347Copy `apps/appview/src/lib/__tests__/forum-agent.test.ts``packages/atproto/src/__tests__/forum-agent.test.ts`. 348 349Only change the import path: 350 351```typescript 352import { 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 360export { 361 isProgrammingError, 362 isNetworkError, 363 isAuthError, 364 isDatabaseError, 365} from "./errors.js"; 366 367export { ForumAgent } from "./forum-agent.js"; 368export type { ForumAgentStatus, ForumAgentState } from "./forum-agent.js"; 369``` 370 371**Step 4: Run atproto tests** 372 373Run: `pnpm --filter @atbb/atproto test` 374Expected: All ForumAgent tests + error tests pass. 375 376**Step 5: Update appview imports** 377 378In `apps/appview/src/lib/app-context.ts`, change line 7: 379```typescript 380// Before: 381import { ForumAgent } from "./forum-agent.js"; 382 383// After: 384import { ForumAgent } from "@atbb/atproto"; 385``` 386 387Also 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 391Delete: 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 397Run: `pnpm build && pnpm test` 398Expected: 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 403git add packages/atproto/ apps/appview/ pnpm-lock.yaml 404git 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 418Create `packages/atproto/src/__tests__/resolve-identity.test.ts`: 419 420```typescript 421import { describe, it, expect, vi } from "vitest"; 422import { resolveIdentity } from "../resolve-identity.js"; 423import { AtpAgent } from "@atproto/api"; 424 425vi.mock("@atproto/api", () => ({ 426 AtpAgent: vi.fn(), 427})); 428 429describe("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 470Run: `pnpm --filter @atbb/atproto test` 471Expected: FAIL — `resolve-identity.js` does not exist. 472 473**Step 3: Implement resolve-identity** 474 475Create `packages/atproto/src/resolve-identity.ts`: 476 477```typescript 478import { AtpAgent } from "@atproto/api"; 479 480export 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 */ 490export 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 506Add the export: 507 508```typescript 509export { resolveIdentity } from "./resolve-identity.js"; 510export type { ResolvedIdentity } from "./resolve-identity.js"; 511``` 512 513**Step 5: Run tests to verify they pass** 514 515Run: `pnpm --filter @atbb/atproto test` 516Expected: All pass. 517 518**Step 6: Commit** 519 520```bash 521git add packages/atproto/ 522git 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 569Note: `@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 586Create `packages/cli/src/index.ts`: 587 588```typescript 589#!/usr/bin/env node 590import { defineCommand, runMain } from "citty"; 591 592const 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 603runMain(main); 604``` 605 606**Step 4: Install dependencies** 607 608Run: `pnpm install` 609 610**Step 5: Verify build** 611 612Run: `pnpm --filter @atbb/cli build` 613Expected: Clean build. `packages/cli/dist/index.js` exists. 614 615**Step 6: Test that the CLI runs** 616 617Run: `node packages/cli/dist/index.js --help` 618Expected: Shows help text with "atBB Forum management CLI". 619 620**Step 7: Commit** 621 622```bash 623git add packages/cli/ pnpm-lock.yaml 624git 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 639Create `packages/cli/src/__tests__/config.test.ts`: 640 641```typescript 642import { describe, it, expect, vi, beforeEach } from "vitest"; 643import { loadCliConfig, type CliConfig } from "../lib/config.js"; 644 645describe("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 692Create `packages/cli/src/__tests__/preflight.test.ts`: 693 694```typescript 695import { describe, it, expect, vi } from "vitest"; 696import { checkEnvironment } from "../lib/preflight.js"; 697import type { CliConfig } from "../lib/config.js"; 698 699describe("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 739Run: `pnpm --filter @atbb/cli test` 740Expected: FAIL — modules don't exist yet. 741 742**Step 4: Implement config.ts** 743 744Create `packages/cli/src/lib/config.ts`: 745 746```typescript 747export 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 */ 760export 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 780Create `packages/cli/src/lib/preflight.ts`: 781 782```typescript 783import type { CliConfig } from "./config.js"; 784 785export interface PreflightResult { 786 ok: boolean; 787 errors: string[]; 788} 789 790/** 791 * Check that all required environment variables are present. 792 */ 793export 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 803Run: `pnpm --filter @atbb/cli test` 804Expected: All pass. 805 806**Step 7: Commit** 807 808```bash 809git add packages/cli/src/lib/ packages/cli/src/__tests__/ 810git 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 823Create `packages/cli/src/__tests__/create-forum.test.ts`: 824 825```typescript 826import { describe, it, expect, vi } from "vitest"; 827import { createForumRecord } from "../lib/steps/create-forum.js"; 828 829describe("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 906Run: `pnpm --filter @atbb/cli test` 907Expected: FAIL — module doesn't exist. 908 909**Step 3: Implement create-forum.ts** 910 911Create `packages/cli/src/lib/steps/create-forum.ts`: 912 913```typescript 914import type { AtpAgent } from "@atproto/api"; 915 916interface CreateForumInput { 917 name: string; 918 description?: string; 919} 920 921interface 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 */ 932export 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 977Run: `pnpm --filter @atbb/cli test` 978Expected: All pass. 979 980**Step 5: Commit** 981 982```bash 983git add packages/cli/src/lib/steps/create-forum.ts packages/cli/src/__tests__/create-forum.test.ts 984git 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 997Create `packages/cli/src/__tests__/seed-roles.test.ts`: 998 999```typescript 1000import { describe, it, expect, vi } from "vitest"; 1001import { seedDefaultRoles, DEFAULT_ROLES } from "../lib/steps/seed-roles.js"; 1002 1003describe("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 1070Run: `pnpm --filter @atbb/cli test` 1071Expected: FAIL. 1072 1073**Step 3: Implement seed-roles.ts** 1074 1075Create `packages/cli/src/lib/steps/seed-roles.ts`: 1076 1077```typescript 1078import type { AtpAgent } from "@atproto/api"; 1079import type { Database } from "@atbb/db"; 1080import { roles } from "@atbb/db"; 1081import { eq } from "drizzle-orm"; 1082 1083interface DefaultRole { 1084 name: string; 1085 description: string; 1086 permissions: string[]; 1087 priority: number; 1088} 1089 1090export 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 1137interface 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 */ 1146export 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 1190Run: `pnpm --filter @atbb/cli test` 1191Expected: All pass. 1192 1193**Step 5: Commit** 1194 1195```bash 1196git add packages/cli/src/lib/steps/seed-roles.ts packages/cli/src/__tests__/seed-roles.test.ts 1197git 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 1210Create `packages/cli/src/__tests__/assign-owner.test.ts`: 1211 1212```typescript 1213import { describe, it, expect, vi } from "vitest"; 1214import { assignOwnerRole } from "../lib/steps/assign-owner.js"; 1215 1216describe("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 1303Run: `pnpm --filter @atbb/cli test` 1304Expected: FAIL. 1305 1306**Step 3: Implement assign-owner.ts** 1307 1308Create `packages/cli/src/lib/steps/assign-owner.ts`: 1309 1310```typescript 1311import type { AtpAgent } from "@atproto/api"; 1312import type { Database } from "@atbb/db"; 1313import { roles, memberships } from "@atbb/db"; 1314import { eq, and } from "drizzle-orm"; 1315 1316interface 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 */ 1330export 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 1390Run: `pnpm --filter @atbb/cli test` 1391Expected: All pass. 1392 1393**Step 5: Commit** 1394 1395```bash 1396git add packages/cli/src/lib/steps/assign-owner.ts packages/cli/src/__tests__/assign-owner.test.ts 1397git 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 1411In `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 1424Run: `pnpm install` 1425 1426**Step 2: Implement the init command** 1427 1428Create `packages/cli/src/commands/init.ts`: 1429 1430```typescript 1431import { defineCommand } from "citty"; 1432import consola from "consola"; 1433import { input } from "@inquirer/prompts"; 1434import { createDb } from "@atbb/db"; 1435import { ForumAgent, resolveIdentity } from "@atbb/atproto"; 1436import { loadCliConfig } from "../lib/config.js"; 1437import { checkEnvironment } from "../lib/preflight.js"; 1438import { createForumRecord } from "../lib/steps/create-forum.js"; 1439import { seedDefaultRoles } from "../lib/steps/seed-roles.js"; 1440import { assignOwnerRole } from "../lib/steps/assign-owner.js"; 1441 1442export 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 1612Replace `packages/cli/src/index.ts`: 1613 1614```typescript 1615#!/usr/bin/env node 1616import { defineCommand, runMain } from "citty"; 1617import { initCommand } from "./commands/init.js"; 1618 1619const 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 1630runMain(main); 1631``` 1632 1633**Step 4: Verify build** 1634 1635Run: `pnpm --filter @atbb/cli build` 1636Expected: Clean build. 1637 1638**Step 5: Verify help output** 1639 1640Run: `node packages/cli/dist/index.js init --help` 1641Expected: Shows init command help with `--forum-name`, `--forum-description`, `--owner` args. 1642 1643**Step 6: Commit** 1644 1645```bash 1646git add packages/cli/ 1647git 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 1660In the builder stage, add the new packages to the COPY commands. After the existing package.json COPY lines, add: 1661 1662```dockerfile 1663COPY packages/atproto/package.json ./packages/atproto/ 1664COPY packages/cli/package.json ./packages/cli/ 1665``` 1666 1667**Step 2: Update Dockerfile runtime stage** 1668 1669In the runtime stage, add: 1670 1671```dockerfile 1672# Copy package files for production install 1673COPY packages/atproto/package.json ./packages/atproto/ 1674COPY packages/cli/package.json ./packages/cli/ 1675 1676# Copy built artifacts from builder stage (add these after existing COPY --from=builder lines) 1677COPY --from=builder /build/packages/atproto/dist ./packages/atproto/dist 1678COPY --from=builder /build/packages/cli/dist ./packages/cli/dist 1679``` 1680 1681**Step 3: Verify Docker build** 1682 1683Run: `docker build -t atbb:test .` 1684Expected: Build succeeds. 1685 1686If 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 1691git add Dockerfile 1692git 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 1701Run: `pnpm build` 1702Expected: All packages build successfully. Turbo handles dependency ordering. 1703 1704**Step 2: Run all tests** 1705 1706Run: `pnpm test` 1707Expected: All tests pass across all packages. 1708 1709**Step 3: Run lint** 1710 1711Run: `pnpm lint` 1712Expected: No type errors. 1713 1714**Step 4: Verify CLI end-to-end (dry run)** 1715 1716Run: `node packages/cli/dist/index.js init --help` 1717Expected: Shows help text. 1718 1719Run: `node packages/cli/dist/index.js init` (without .env, expect graceful failure) 1720Expected: "Missing required environment variables" error with list. 1721 1722**Step 5: Final commit if any cleanup needed** 1723 1724If any adjustments were made during verification: 1725 1726```bash 1727git add -A 1728git commit -m "chore: cleanup after integration verification" 1729```