WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1# 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```