# Bootstrap CLI Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **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. **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. **Tech Stack:** citty (CLI framework), consola (styled output), @inquirer/prompts (interactive input), @atproto/api (PDS operations), @atbb/db (database), vitest (testing). **Design doc:** `docs/plans/2026-02-18-bootstrap-cli-design.md` --- ## Task 1: Create `packages/atproto` package scaffolding **Files:** - Create: `packages/atproto/package.json` - Create: `packages/atproto/tsconfig.json` - Create: `packages/atproto/src/index.ts` **Step 1: Create package.json** ```json { "name": "@atbb/atproto", "version": "0.1.0", "private": true, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "scripts": { "build": "tsc", "lint": "tsc --noEmit", "lint:fix": "oxlint --fix src/", "clean": "rm -rf dist", "test": "vitest run" }, "dependencies": { "@atproto/api": "^0.15.0" }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0" } } ``` **Step 2: Create tsconfig.json** ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*.ts"] } ``` **Step 3: Create empty index.ts** ```typescript // @atbb/atproto — Shared AT Protocol utilities // Exports will be added as modules are extracted from appview. ``` **Step 4: Install dependencies** Run: `pnpm install` **Step 5: Verify build** Run: `pnpm --filter @atbb/atproto build` Expected: Clean build, `packages/atproto/dist/index.js` exists. **Step 6: Commit** ```bash git add packages/atproto/ git commit -m "chore: scaffold @atbb/atproto package" ``` --- ## Task 2: Extract error helpers into `packages/atproto` **Files:** - Create: `packages/atproto/src/errors.ts` - Create: `packages/atproto/src/__tests__/errors.test.ts` - Modify: `packages/atproto/src/index.ts` - Modify: `apps/appview/src/lib/errors.ts` - Modify: `apps/appview/src/routes/posts.ts:7` - Modify: `apps/appview/src/routes/admin.ts:8` - Modify: `apps/appview/src/routes/mod.ts:7` - Modify: `apps/appview/src/routes/topics.ts:10` - Modify: `apps/appview/src/lib/ban-enforcer.ts:4` - Modify: `apps/appview/src/routes/__tests__/helpers.test.ts:2` **Step 1: Write the error helper tests** Create `packages/atproto/src/__tests__/errors.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { isProgrammingError, isNetworkError, isAuthError, isDatabaseError } from "../errors.js"; describe("isProgrammingError", () => { it("returns true for TypeError", () => { expect(isProgrammingError(new TypeError("x is not a function"))).toBe(true); }); it("returns true for ReferenceError", () => { expect(isProgrammingError(new ReferenceError("x is not defined"))).toBe(true); }); it("returns true for SyntaxError", () => { expect(isProgrammingError(new SyntaxError("unexpected token"))).toBe(true); }); it("returns false for generic Error", () => { expect(isProgrammingError(new Error("something failed"))).toBe(false); }); it("returns false for non-error values", () => { expect(isProgrammingError("string")).toBe(false); expect(isProgrammingError(null)).toBe(false); }); }); describe("isNetworkError", () => { it("returns true for fetch failed", () => { expect(isNetworkError(new Error("fetch failed"))).toBe(true); }); it("returns true for ECONNREFUSED", () => { expect(isNetworkError(new Error("ECONNREFUSED"))).toBe(true); }); it("returns true for timeout", () => { expect(isNetworkError(new Error("request timeout"))).toBe(true); }); it("returns false for generic Error", () => { expect(isNetworkError(new Error("something else"))).toBe(false); }); it("returns false for non-Error values", () => { expect(isNetworkError("string")).toBe(false); }); }); describe("isAuthError", () => { it("returns true for invalid credentials", () => { expect(isAuthError(new Error("Invalid identifier or password"))).toBe(true); }); it("returns true for authentication failed", () => { expect(isAuthError(new Error("Authentication failed"))).toBe(true); }); it("returns true for unauthorized", () => { expect(isAuthError(new Error("Unauthorized"))).toBe(true); }); it("returns false for network errors", () => { expect(isAuthError(new Error("fetch failed"))).toBe(false); }); it("returns false for non-Error values", () => { expect(isAuthError("string")).toBe(false); }); }); describe("isDatabaseError", () => { it("returns true for pool errors", () => { expect(isDatabaseError(new Error("pool exhausted"))).toBe(true); }); it("returns true for postgres errors", () => { expect(isDatabaseError(new Error("postgres connection lost"))).toBe(true); }); it("returns false for generic errors", () => { expect(isDatabaseError(new Error("something else"))).toBe(false); }); }); ``` **Step 2: Run tests to verify they fail** Run: `pnpm --filter @atbb/atproto test` Expected: FAIL — `errors.js` module does not exist yet. **Step 3: Create `packages/atproto/src/errors.ts`** Copy the error helpers from `apps/appview/src/lib/errors.ts` and add `isAuthError` from `apps/appview/src/lib/forum-agent.ts`: ```typescript /** * Check if an error is a programming error (code bug). * Programming errors should be re-thrown, not caught. */ export function isProgrammingError(error: unknown): boolean { return ( error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError ); } /** * Check if an error is a network error (temporary). * Network errors should return 503 (retry later). */ export function isNetworkError(error: unknown): boolean { if (!(error instanceof Error)) return false; const msg = error.message.toLowerCase(); return ( msg.includes("fetch failed") || msg.includes("network") || msg.includes("econnrefused") || msg.includes("enotfound") || msg.includes("timeout") || msg.includes("econnreset") || msg.includes("enetunreach") || msg.includes("service unavailable") ); } /** * Check if an error is an authentication error (wrong credentials). * Auth errors should NOT be retried to avoid account lockouts. */ export function isAuthError(error: unknown): boolean { if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); return ( message.includes("invalid identifier") || message.includes("invalid password") || message.includes("authentication failed") || message.includes("unauthorized") ); } /** * Check if an error represents a database-layer failure. * These errors indicate temporary unavailability — user should retry. */ export function isDatabaseError(error: unknown): boolean { if (!(error instanceof Error)) return false; const msg = error.message.toLowerCase(); return ( msg.includes("pool") || msg.includes("postgres") || msg.includes("database") || msg.includes("sql") || msg.includes("query") ); } ``` **Step 4: Update `packages/atproto/src/index.ts`** ```typescript export { isProgrammingError, isNetworkError, isAuthError, isDatabaseError, } from "./errors.js"; ``` **Step 5: Run tests to verify they pass** Run: `pnpm --filter @atbb/atproto test` Expected: All PASS. **Step 6: Update appview to import from `@atbb/atproto`** Add `@atbb/atproto` dependency to `apps/appview/package.json`: ```json "@atbb/atproto": "workspace:*" ``` Then run: `pnpm install` Replace `apps/appview/src/lib/errors.ts` with a re-export shim: ```typescript // Re-export from shared package for backward compatibility. // Appview routes can gradually migrate to importing from @atbb/atproto directly. export { isProgrammingError, isNetworkError, isAuthError, isDatabaseError, } from "@atbb/atproto"; ``` **Step 7: Verify appview still builds and tests pass** Run: `pnpm build && pnpm --filter @atbb/appview test` Expected: All pass — no behavioral changes. **Step 8: Commit** ```bash git add packages/atproto/ apps/appview/package.json apps/appview/src/lib/errors.ts pnpm-lock.yaml git commit -m "refactor: extract error helpers into @atbb/atproto" ``` --- ## Task 3: Move ForumAgent into `packages/atproto` **Files:** - Create: `packages/atproto/src/forum-agent.ts` - Create: `packages/atproto/src/__tests__/forum-agent.test.ts` - Modify: `packages/atproto/src/index.ts` - Modify: `apps/appview/src/lib/app-context.ts:7` - Delete: `apps/appview/src/lib/forum-agent.ts` - Delete: `apps/appview/src/lib/__tests__/forum-agent.test.ts` **Step 1: Copy ForumAgent to packages/atproto** Copy `apps/appview/src/lib/forum-agent.ts` → `packages/atproto/src/forum-agent.ts`. The only change: replace the local `isAuthError` / `isNetworkError` helper functions at the top of the file with an import: ```typescript import { isAuthError, isNetworkError } from "./errors.js"; ``` Remove the `isAuthError` and `isNetworkError` function definitions from the file (lines 7-39 of the original). They now live in `errors.ts`. **Step 2: Copy ForumAgent tests** Copy `apps/appview/src/lib/__tests__/forum-agent.test.ts` → `packages/atproto/src/__tests__/forum-agent.test.ts`. Only change the import path: ```typescript import { ForumAgent } from "../forum-agent.js"; ``` (This is already the correct relative path — it doesn't change.) **Step 3: Update `packages/atproto/src/index.ts`** ```typescript export { isProgrammingError, isNetworkError, isAuthError, isDatabaseError, } from "./errors.js"; export { ForumAgent } from "./forum-agent.js"; export type { ForumAgentStatus, ForumAgentState } from "./forum-agent.js"; ``` **Step 4: Run atproto tests** Run: `pnpm --filter @atbb/atproto test` Expected: All ForumAgent tests + error tests pass. **Step 5: Update appview imports** In `apps/appview/src/lib/app-context.ts`, change line 7: ```typescript // Before: import { ForumAgent } from "./forum-agent.js"; // After: import { ForumAgent } from "@atbb/atproto"; ``` Also update `apps/appview/src/lib/app-context.ts` line 8 — remove the `AppConfig` type import from `"./config.js"` if it imported ForumAgent types (it doesn't — just verify). **Step 6: Delete old files from appview** Delete: - `apps/appview/src/lib/forum-agent.ts` - `apps/appview/src/lib/__tests__/forum-agent.test.ts` **Step 7: Verify everything builds and tests pass** Run: `pnpm build && pnpm test` Expected: All packages build. All tests pass. ForumAgent tests now run under `@atbb/atproto` instead of `@atbb/appview`. **Step 8: Commit** ```bash git add packages/atproto/ apps/appview/ pnpm-lock.yaml git commit -m "refactor: move ForumAgent to @atbb/atproto package" ``` --- ## Task 4: Add identity resolution to `packages/atproto` **Files:** - Create: `packages/atproto/src/resolve-identity.ts` - Create: `packages/atproto/src/__tests__/resolve-identity.test.ts` - Modify: `packages/atproto/src/index.ts` **Step 1: Write the failing tests** Create `packages/atproto/src/__tests__/resolve-identity.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { resolveIdentity } from "../resolve-identity.js"; import { AtpAgent } from "@atproto/api"; vi.mock("@atproto/api", () => ({ AtpAgent: vi.fn(), })); describe("resolveIdentity", () => { it("returns DID directly when input starts with 'did:'", async () => { const result = await resolveIdentity("did:plc:abc123", "https://bsky.social"); expect(result).toEqual({ did: "did:plc:abc123" }); // AtpAgent should NOT be instantiated for DID input expect(AtpAgent).not.toHaveBeenCalled(); }); it("resolves a handle to a DID via PDS", async () => { const mockResolveHandle = vi.fn().mockResolvedValue({ data: { did: "did:plc:resolved123" }, }); (AtpAgent as any).mockImplementation(() => ({ resolveHandle: mockResolveHandle, })); const result = await resolveIdentity("alice.bsky.social", "https://bsky.social"); expect(result).toEqual({ did: "did:plc:resolved123", handle: "alice.bsky.social", }); expect(AtpAgent).toHaveBeenCalledWith({ service: "https://bsky.social" }); expect(mockResolveHandle).toHaveBeenCalledWith({ handle: "alice.bsky.social" }); }); it("throws when handle resolution fails", async () => { (AtpAgent as any).mockImplementation(() => ({ resolveHandle: vi.fn().mockRejectedValue(new Error("Unable to resolve handle")), })); await expect( resolveIdentity("nonexistent.bsky.social", "https://bsky.social") ).rejects.toThrow("Unable to resolve handle"); }); }); ``` **Step 2: Run tests to verify they fail** Run: `pnpm --filter @atbb/atproto test` Expected: FAIL — `resolve-identity.js` does not exist. **Step 3: Implement resolve-identity** Create `packages/atproto/src/resolve-identity.ts`: ```typescript import { AtpAgent } from "@atproto/api"; export interface ResolvedIdentity { did: string; handle?: string; } /** * Resolve a handle or DID string to a confirmed DID. * If the input already starts with "did:", returns it directly. * Otherwise, treats it as a handle and resolves via the PDS. */ export async function resolveIdentity( input: string, pdsUrl: string ): Promise { if (input.startsWith("did:")) { return { did: input }; } const agent = new AtpAgent({ service: pdsUrl }); const res = await agent.resolveHandle({ handle: input }); return { did: res.data.did, handle: input }; } ``` **Step 4: Update `packages/atproto/src/index.ts`** Add the export: ```typescript export { resolveIdentity } from "./resolve-identity.js"; export type { ResolvedIdentity } from "./resolve-identity.js"; ``` **Step 5: Run tests to verify they pass** Run: `pnpm --filter @atbb/atproto test` Expected: All pass. **Step 6: Commit** ```bash git add packages/atproto/ git commit -m "feat: add identity resolution helper to @atbb/atproto" ``` --- ## Task 5: Create `packages/cli` package scaffolding **Files:** - Create: `packages/cli/package.json` - Create: `packages/cli/tsconfig.json` - Create: `packages/cli/src/index.ts` **Step 1: Create package.json** ```json { "name": "@atbb/cli", "version": "0.1.0", "private": true, "type": "module", "bin": { "atbb": "./dist/index.js" }, "scripts": { "build": "tsc", "dev": "tsx --env-file=../../.env src/index.ts", "lint": "tsc --noEmit", "lint:fix": "oxlint --fix src/", "clean": "rm -rf dist", "test": "vitest run" }, "dependencies": { "@atbb/atproto": "workspace:*", "@atbb/db": "workspace:*", "@atproto/api": "^0.15.0", "citty": "^0.1.6", "consola": "^3.4.0" }, "devDependencies": { "@inquirer/prompts": "^7.0.0", "@types/node": "^22.0.0", "tsx": "^4.0.0", "typescript": "^5.7.0" } } ``` Note: `@inquirer/prompts` is in devDependencies for now — we'll move it to dependencies once we implement interactive prompts in Task 8. For Task 5 we only need the shell. **Step 2: Create tsconfig.json** ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*.ts"] } ``` **Step 3: Create minimal CLI entrypoint** Create `packages/cli/src/index.ts`: ```typescript #!/usr/bin/env node import { defineCommand, runMain } from "citty"; const main = defineCommand({ meta: { name: "atbb", version: "0.1.0", description: "atBB Forum management CLI", }, subCommands: { // init command will be added in Task 8 }, }); runMain(main); ``` **Step 4: Install dependencies** Run: `pnpm install` **Step 5: Verify build** Run: `pnpm --filter @atbb/cli build` Expected: Clean build. `packages/cli/dist/index.js` exists. **Step 6: Test that the CLI runs** Run: `node packages/cli/dist/index.js --help` Expected: Shows help text with "atBB Forum management CLI". **Step 7: Commit** ```bash git add packages/cli/ pnpm-lock.yaml git commit -m "chore: scaffold @atbb/cli package with citty" ``` --- ## Task 6: Implement CLI config loader and preflight checks **Files:** - Create: `packages/cli/src/lib/config.ts` - Create: `packages/cli/src/lib/preflight.ts` - Create: `packages/cli/src/__tests__/config.test.ts` - Create: `packages/cli/src/__tests__/preflight.test.ts` **Step 1: Write config tests** Create `packages/cli/src/__tests__/config.test.ts`: ```typescript import { describe, it, expect, vi, beforeEach } from "vitest"; import { loadCliConfig, type CliConfig } from "../lib/config.js"; describe("loadCliConfig", () => { beforeEach(() => { vi.unstubAllEnvs(); }); it("loads all required env vars", () => { vi.stubEnv("DATABASE_URL", "postgres://localhost:5432/atbb"); vi.stubEnv("FORUM_DID", "did:plc:test123"); vi.stubEnv("PDS_URL", "https://bsky.social"); vi.stubEnv("FORUM_HANDLE", "forum.example.com"); vi.stubEnv("FORUM_PASSWORD", "secret"); const config = loadCliConfig(); expect(config.databaseUrl).toBe("postgres://localhost:5432/atbb"); expect(config.forumDid).toBe("did:plc:test123"); expect(config.pdsUrl).toBe("https://bsky.social"); expect(config.forumHandle).toBe("forum.example.com"); expect(config.forumPassword).toBe("secret"); }); it("returns missing fields list when env vars are absent", () => { // No env vars set const config = loadCliConfig(); expect(config.missing).toContain("DATABASE_URL"); expect(config.missing).toContain("FORUM_DID"); expect(config.missing).toContain("FORUM_HANDLE"); expect(config.missing).toContain("FORUM_PASSWORD"); }); it("defaults PDS_URL to https://bsky.social", () => { vi.stubEnv("DATABASE_URL", "postgres://localhost/atbb"); vi.stubEnv("FORUM_DID", "did:plc:test"); vi.stubEnv("FORUM_HANDLE", "handle"); vi.stubEnv("FORUM_PASSWORD", "pass"); const config = loadCliConfig(); expect(config.pdsUrl).toBe("https://bsky.social"); expect(config.missing).toHaveLength(0); }); }); ``` **Step 2: Write preflight tests** Create `packages/cli/src/__tests__/preflight.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { checkEnvironment } from "../lib/preflight.js"; import type { CliConfig } from "../lib/config.js"; describe("checkEnvironment", () => { it("returns success when all required vars are present", () => { const config: CliConfig = { databaseUrl: "postgres://localhost/atbb", forumDid: "did:plc:test", pdsUrl: "https://bsky.social", forumHandle: "forum.example.com", forumPassword: "secret", missing: [], }; const result = checkEnvironment(config); expect(result.ok).toBe(true); expect(result.errors).toHaveLength(0); }); it("returns errors when required vars are missing", () => { const config: CliConfig = { databaseUrl: "", forumDid: "", pdsUrl: "https://bsky.social", forumHandle: "", forumPassword: "", missing: ["DATABASE_URL", "FORUM_DID", "FORUM_HANDLE", "FORUM_PASSWORD"], }; const result = checkEnvironment(config); expect(result.ok).toBe(false); expect(result.errors).toContain("DATABASE_URL"); expect(result.errors).toContain("FORUM_DID"); expect(result.errors).toContain("FORUM_HANDLE"); expect(result.errors).toContain("FORUM_PASSWORD"); }); }); ``` **Step 3: Run tests to verify they fail** Run: `pnpm --filter @atbb/cli test` Expected: FAIL — modules don't exist yet. **Step 4: Implement config.ts** Create `packages/cli/src/lib/config.ts`: ```typescript export interface CliConfig { databaseUrl: string; forumDid: string; pdsUrl: string; forumHandle: string; forumPassword: string; missing: string[]; } /** * Load CLI configuration from environment variables. * Returns a config object with a `missing` array listing absent required vars. */ export function loadCliConfig(): CliConfig { const missing: string[] = []; const databaseUrl = process.env.DATABASE_URL ?? ""; const forumDid = process.env.FORUM_DID ?? ""; const pdsUrl = process.env.PDS_URL ?? "https://bsky.social"; const forumHandle = process.env.FORUM_HANDLE ?? ""; const forumPassword = process.env.FORUM_PASSWORD ?? ""; if (!databaseUrl) missing.push("DATABASE_URL"); if (!forumDid) missing.push("FORUM_DID"); if (!forumHandle) missing.push("FORUM_HANDLE"); if (!forumPassword) missing.push("FORUM_PASSWORD"); return { databaseUrl, forumDid, pdsUrl, forumHandle, forumPassword, missing }; } ``` **Step 5: Implement preflight.ts** Create `packages/cli/src/lib/preflight.ts`: ```typescript import type { CliConfig } from "./config.js"; export interface PreflightResult { ok: boolean; errors: string[]; } /** * Check that all required environment variables are present. */ export function checkEnvironment(config: CliConfig): PreflightResult { if (config.missing.length === 0) { return { ok: true, errors: [] }; } return { ok: false, errors: config.missing }; } ``` **Step 6: Run tests to verify they pass** Run: `pnpm --filter @atbb/cli test` Expected: All pass. **Step 7: Commit** ```bash git add packages/cli/src/lib/ packages/cli/src/__tests__/ git commit -m "feat(cli): add config loader and preflight environment checks" ``` --- ## Task 7: Implement create-forum step **Files:** - Create: `packages/cli/src/lib/steps/create-forum.ts` - Create: `packages/cli/src/__tests__/create-forum.test.ts` **Step 1: Write the failing tests** Create `packages/cli/src/__tests__/create-forum.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { createForumRecord } from "../lib/steps/create-forum.js"; describe("createForumRecord", () => { const forumDid = "did:plc:testforum"; function mockAgent(overrides: Record = {}) { return { com: { atproto: { repo: { getRecord: vi.fn().mockRejectedValue({ status: 400 }), createRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${forumDid}/space.atbb.forum.forum/self`, cid: "bafytest" }, }), ...overrides, }, }, }, } as any; } it("creates forum record when it does not exist", async () => { const agent = mockAgent(); const result = await createForumRecord(agent, forumDid, { name: "My Forum", description: "A test forum", }); expect(result.created).toBe(true); expect(result.uri).toContain("space.atbb.forum.forum/self"); expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: forumDid, collection: "space.atbb.forum.forum", rkey: "self", record: expect.objectContaining({ $type: "space.atbb.forum.forum", name: "My Forum", description: "A test forum", }), }) ); }); it("skips creation when forum record already exists", async () => { const agent = mockAgent({ getRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${forumDid}/space.atbb.forum.forum/self`, cid: "bafyexisting", value: { name: "Existing Forum" }, }, }), }); const result = await createForumRecord(agent, forumDid, { name: "My Forum", }); expect(result.created).toBe(false); expect(result.skipped).toBe(true); expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); }); it("throws when PDS write fails", async () => { const agent = mockAgent({ createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")), }); await expect( createForumRecord(agent, forumDid, { name: "My Forum" }) ).rejects.toThrow("PDS write failed"); }); }); ``` **Step 2: Run tests to verify they fail** Run: `pnpm --filter @atbb/cli test` Expected: FAIL — module doesn't exist. **Step 3: Implement create-forum.ts** Create `packages/cli/src/lib/steps/create-forum.ts`: ```typescript import type { AtpAgent } from "@atproto/api"; interface CreateForumInput { name: string; description?: string; } interface CreateForumResult { created: boolean; skipped: boolean; uri?: string; existingName?: string; } /** * Create the space.atbb.forum.forum/self record on the Forum DID's PDS. * Idempotent: skips if the record already exists. */ export async function createForumRecord( agent: AtpAgent, forumDid: string, input: CreateForumInput ): Promise { // Check if forum record already exists try { const existing = await agent.com.atproto.repo.getRecord({ repo: forumDid, collection: "space.atbb.forum.forum", rkey: "self", }); return { created: false, skipped: true, uri: existing.data.uri, existingName: (existing.data.value as any)?.name, }; } catch { // Record doesn't exist — continue to create it } const response = await agent.com.atproto.repo.createRecord({ repo: forumDid, collection: "space.atbb.forum.forum", rkey: "self", record: { $type: "space.atbb.forum.forum", name: input.name, ...(input.description && { description: input.description }), createdAt: new Date().toISOString(), }, }); return { created: true, skipped: false, uri: response.data.uri, }; } ``` **Step 4: Run tests to verify they pass** Run: `pnpm --filter @atbb/cli test` Expected: All pass. **Step 5: Commit** ```bash git add packages/cli/src/lib/steps/create-forum.ts packages/cli/src/__tests__/create-forum.test.ts git commit -m "feat(cli): implement create-forum bootstrap step" ``` --- ## Task 8: Implement seed-roles step **Files:** - Create: `packages/cli/src/lib/steps/seed-roles.ts` - Create: `packages/cli/src/__tests__/seed-roles.test.ts` **Step 1: Write the failing tests** Create `packages/cli/src/__tests__/seed-roles.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { seedDefaultRoles, DEFAULT_ROLES } from "../lib/steps/seed-roles.js"; describe("seedDefaultRoles", () => { const forumDid = "did:plc:testforum"; function mockDb(existingRoleNames: string[] = []) { return { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => { // Return empty array for non-existing, populated for existing const roleName = existingRoleNames.length > 0 ? existingRoleNames.shift() : undefined; return roleName ? [{ name: roleName }] : []; }), }), }), }), } as any; } function mockAgent() { return { com: { atproto: { repo: { createRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${forumDid}/space.atbb.forum.role/test`, cid: "bafytest" }, }), }, }, }, } as any; } it("exports DEFAULT_ROLES with correct structure", () => { expect(DEFAULT_ROLES).toHaveLength(4); expect(DEFAULT_ROLES[0].name).toBe("Owner"); expect(DEFAULT_ROLES[0].priority).toBe(0); expect(DEFAULT_ROLES[3].name).toBe("Member"); expect(DEFAULT_ROLES[3].priority).toBe(30); }); it("creates all roles when none exist", async () => { const db = mockDb(); const agent = mockAgent(); const result = await seedDefaultRoles(db, agent, forumDid); expect(result.created).toBe(4); expect(result.skipped).toBe(0); expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(4); }); it("skips existing roles", async () => { // Simulate Owner and Admin already existing const db = mockDb(["Owner", "Admin"]); const agent = mockAgent(); const result = await seedDefaultRoles(db, agent, forumDid); expect(result.skipped).toBe(2); expect(result.created).toBe(2); }); }); ``` **Step 2: Run tests to verify they fail** Run: `pnpm --filter @atbb/cli test` Expected: FAIL. **Step 3: Implement seed-roles.ts** Create `packages/cli/src/lib/steps/seed-roles.ts`: ```typescript import type { AtpAgent } from "@atproto/api"; import type { Database } from "@atbb/db"; import { roles } from "@atbb/db"; import { eq } from "drizzle-orm"; interface DefaultRole { name: string; description: string; permissions: string[]; priority: number; } export const DEFAULT_ROLES: DefaultRole[] = [ { name: "Owner", description: "Forum owner with full control", permissions: ["*"], priority: 0, }, { name: "Admin", description: "Can manage forum structure and users", permissions: [ "space.atbb.permission.manageCategories", "space.atbb.permission.manageRoles", "space.atbb.permission.manageMembers", "space.atbb.permission.moderatePosts", "space.atbb.permission.banUsers", "space.atbb.permission.pinTopics", "space.atbb.permission.lockTopics", "space.atbb.permission.createTopics", "space.atbb.permission.createPosts", ], priority: 10, }, { name: "Moderator", description: "Can moderate content and users", permissions: [ "space.atbb.permission.moderatePosts", "space.atbb.permission.banUsers", "space.atbb.permission.pinTopics", "space.atbb.permission.lockTopics", "space.atbb.permission.createTopics", "space.atbb.permission.createPosts", ], priority: 20, }, { name: "Member", description: "Regular forum member", permissions: [ "space.atbb.permission.createTopics", "space.atbb.permission.createPosts", ], priority: 30, }, ]; interface SeedRolesResult { created: number; skipped: number; } /** * Seed default roles to Forum DID's PDS. * Idempotent: checks for existing roles by name before creating. */ export async function seedDefaultRoles( db: Database, agent: AtpAgent, forumDid: string ): Promise { let created = 0; let skipped = 0; for (const defaultRole of DEFAULT_ROLES) { // Check if role already exists by name const [existingRole] = await db .select() .from(roles) .where(eq(roles.name, defaultRole.name)) .limit(1); if (existingRole) { skipped++; continue; } // Create role record on Forum DID's PDS await agent.com.atproto.repo.createRecord({ repo: forumDid, collection: "space.atbb.forum.role", record: { $type: "space.atbb.forum.role", name: defaultRole.name, description: defaultRole.description, permissions: defaultRole.permissions, priority: defaultRole.priority, createdAt: new Date().toISOString(), }, }); created++; } return { created, skipped }; } ``` **Step 4: Run tests to verify they pass** Run: `pnpm --filter @atbb/cli test` Expected: All pass. **Step 5: Commit** ```bash git add packages/cli/src/lib/steps/seed-roles.ts packages/cli/src/__tests__/seed-roles.test.ts git commit -m "feat(cli): implement seed-roles bootstrap step" ``` --- ## Task 9: Implement assign-owner step **Files:** - Create: `packages/cli/src/lib/steps/assign-owner.ts` - Create: `packages/cli/src/__tests__/assign-owner.test.ts` **Step 1: Write the failing tests** Create `packages/cli/src/__tests__/assign-owner.test.ts`: ```typescript import { describe, it, expect, vi } from "vitest"; import { assignOwnerRole } from "../lib/steps/assign-owner.js"; describe("assignOwnerRole", () => { const forumDid = "did:plc:testforum"; const ownerDid = "did:plc:owner123"; function mockDb(options: { ownerRole?: any; existingMembership?: any } = {}) { const selectMock = vi.fn(); // First call: find Owner role // Second call: find existing membership let callCount = 0; selectMock.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) { return options.ownerRole ? [options.ownerRole] : []; } return options.existingMembership ? [options.existingMembership] : []; }), }), }), })); return { select: selectMock } as any; } function mockAgent() { return { com: { atproto: { repo: { createRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${forumDid}/space.atbb.membership/owner`, cid: "bafytest" }, }), }, }, }, } as any; } const ownerRole = { id: 1n, did: forumDid, rkey: "owner", cid: "bafyrole", name: "Owner", priority: 0, }; it("assigns owner role when user has no existing role", async () => { const db = mockDb({ ownerRole }); const agent = mockAgent(); const result = await assignOwnerRole(db, agent, forumDid, ownerDid); expect(result.assigned).toBe(true); expect(result.skipped).toBe(false); }); it("skips when user already has Owner role", async () => { const db = mockDb({ ownerRole, existingMembership: { did: ownerDid, roleUri: `at://${forumDid}/space.atbb.forum.role/owner` }, }); const agent = mockAgent(); const result = await assignOwnerRole(db, agent, forumDid, ownerDid); expect(result.assigned).toBe(false); expect(result.skipped).toBe(true); expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); }); it("throws when Owner role is not found in database", async () => { const db = mockDb({ ownerRole: null }); const agent = mockAgent(); await expect( assignOwnerRole(db, agent, forumDid, ownerDid) ).rejects.toThrow("Owner role not found"); }); }); ``` **Step 2: Run tests to verify they fail** Run: `pnpm --filter @atbb/cli test` Expected: FAIL. **Step 3: Implement assign-owner.ts** Create `packages/cli/src/lib/steps/assign-owner.ts`: ```typescript import type { AtpAgent } from "@atproto/api"; import type { Database } from "@atbb/db"; import { roles, memberships } from "@atbb/db"; import { eq, and } from "drizzle-orm"; interface AssignOwnerResult { assigned: boolean; skipped: boolean; roleUri?: string; } /** * Assign the Owner role to a user. * Idempotent: skips if the user already has the Owner role. * * This writes a membership record on the Forum DID's PDS that links * the owner's DID to the Owner role. The firehose indexer will pick * this up and populate the database. */ export async function assignOwnerRole( db: Database, agent: AtpAgent, forumDid: string, ownerDid: string ): Promise { // Find the Owner role in the database const [ownerRole] = await db .select() .from(roles) .where(eq(roles.name, "Owner")) .limit(1); if (!ownerRole) { throw new Error( "Owner role not found in database. Run role seeding first." ); } const roleUri = `at://${ownerRole.did}/space.atbb.forum.role/${ownerRole.rkey}`; // Check if user already has a membership with this role const [existingMembership] = await db .select() .from(memberships) .where(and(eq(memberships.did, ownerDid), eq(memberships.roleUri, roleUri))) .limit(1); if (existingMembership) { return { assigned: false, skipped: true, roleUri }; } // Write membership record assigning the Owner role // This is written on the forum DID's repo (not the user's) // because the CLI has the forum credentials, not the user's credentials. await agent.com.atproto.repo.createRecord({ repo: forumDid, collection: "space.atbb.membership", record: { $type: "space.atbb.membership", did: ownerDid, forum: { uri: `at://${forumDid}/space.atbb.forum.forum/self`, cid: "pending", // Will be updated by indexer }, role: { uri: roleUri, cid: ownerRole.cid, }, joinedAt: new Date().toISOString(), createdAt: new Date().toISOString(), }, }); return { assigned: true, skipped: false, roleUri }; } ``` **Step 4: Run tests to verify they pass** Run: `pnpm --filter @atbb/cli test` Expected: All pass. **Step 5: Commit** ```bash git add packages/cli/src/lib/steps/assign-owner.ts packages/cli/src/__tests__/assign-owner.test.ts git commit -m "feat(cli): implement assign-owner bootstrap step" ``` --- ## Task 10: Wire up the `init` command **Files:** - Create: `packages/cli/src/commands/init.ts` - Modify: `packages/cli/src/index.ts` - Modify: `packages/cli/package.json` (move `@inquirer/prompts` to dependencies) **Step 1: Move `@inquirer/prompts` to dependencies** In `packages/cli/package.json`, move `@inquirer/prompts` from `devDependencies` to `dependencies`: ```json "dependencies": { "@atbb/atproto": "workspace:*", "@atbb/db": "workspace:*", "@atproto/api": "^0.15.0", "@inquirer/prompts": "^7.0.0", "citty": "^0.1.6", "consola": "^3.4.0" } ``` Run: `pnpm install` **Step 2: Implement the init command** Create `packages/cli/src/commands/init.ts`: ```typescript import { defineCommand } from "citty"; import consola from "consola"; import { input } from "@inquirer/prompts"; import { createDb } from "@atbb/db"; import { ForumAgent, resolveIdentity } from "@atbb/atproto"; import { loadCliConfig } from "../lib/config.js"; import { checkEnvironment } from "../lib/preflight.js"; import { createForumRecord } from "../lib/steps/create-forum.js"; import { seedDefaultRoles } from "../lib/steps/seed-roles.js"; import { assignOwnerRole } from "../lib/steps/assign-owner.js"; export const initCommand = defineCommand({ meta: { name: "init", description: "Bootstrap a new atBB forum instance", }, args: { "forum-name": { type: "string", description: "Forum name", }, "forum-description": { type: "string", description: "Forum description", }, owner: { type: "string", description: "Owner handle or DID (e.g., alice.bsky.social or did:plc:abc123)", }, }, async run({ args }) { consola.box("atBB Forum Setup"); // Step 0: Preflight checks consola.start("Checking environment..."); const config = loadCliConfig(); const envCheck = checkEnvironment(config); if (!envCheck.ok) { consola.error("Missing required environment variables:"); for (const name of envCheck.errors) { consola.error(` - ${name}`); } consola.info("Set these in your .env file or environment, then re-run."); process.exit(1); } consola.success(`DATABASE_URL configured`); consola.success(`FORUM_DID: ${config.forumDid}`); consola.success(`PDS_URL: ${config.pdsUrl}`); consola.success(`FORUM_HANDLE / FORUM_PASSWORD configured`); // Step 1: Connect to database consola.start("Connecting to database..."); let db; try { db = createDb(config.databaseUrl); // Quick connectivity check await db.execute("SELECT 1"); consola.success("Database connection successful"); } catch (error) { consola.error("Failed to connect to database:", error instanceof Error ? error.message : String(error)); consola.info("Check your DATABASE_URL and ensure the database is running."); process.exit(1); } // Step 2: Authenticate as Forum DID consola.start("Authenticating as Forum DID..."); const forumAgent = new ForumAgent(config.pdsUrl, config.forumHandle, config.forumPassword); await forumAgent.initialize(); if (!forumAgent.isAuthenticated()) { const status = forumAgent.getStatus(); consola.error(`Failed to authenticate: ${status.error}`); if (status.status === "failed") { consola.info("Check your FORUM_HANDLE and FORUM_PASSWORD."); } await forumAgent.shutdown(); process.exit(1); } const agent = forumAgent.getAgent()!; consola.success(`Authenticated as ${config.forumHandle}`); // Step 3: Create forum record consola.log(""); consola.info("Step 1: Create Forum Record"); const forumName = args["forum-name"] ?? await input({ message: "Forum name:", default: "My Forum", }); const forumDescription = args["forum-description"] ?? await input({ message: "Forum description (optional):", }); try { const forumResult = await createForumRecord(agent, config.forumDid, { name: forumName, ...(forumDescription && { description: forumDescription }), }); if (forumResult.skipped) { consola.warn(`Forum record already exists: "${forumResult.existingName}"`); } else { consola.success(`Created forum record: ${forumResult.uri}`); } } catch (error) { consola.error("Failed to create forum record:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); process.exit(1); } // Step 4: Seed default roles consola.log(""); consola.info("Step 2: Seed Default Roles"); try { const rolesResult = await seedDefaultRoles(db, agent, config.forumDid); if (rolesResult.created > 0) { consola.success(`Created ${rolesResult.created} role(s)`); } if (rolesResult.skipped > 0) { consola.warn(`Skipped ${rolesResult.skipped} existing role(s)`); } } catch (error) { consola.error("Failed to seed roles:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); process.exit(1); } // Step 5: Assign owner consola.log(""); consola.info("Step 3: Assign Forum Owner"); const ownerInput = args.owner ?? await input({ message: "Owner handle or DID:", }); try { consola.start("Resolving identity..."); const identity = await resolveIdentity(ownerInput, config.pdsUrl); if (identity.handle) { consola.success(`Resolved ${identity.handle} to ${identity.did}`); } const ownerResult = await assignOwnerRole(db, agent, config.forumDid, identity.did); if (ownerResult.skipped) { consola.warn(`${ownerInput} already has the Owner role`); } else { consola.success(`Assigned Owner role to ${ownerInput}`); } } catch (error) { consola.error("Failed to assign owner:", error instanceof Error ? error.message : String(error)); await forumAgent.shutdown(); process.exit(1); } // Done! await forumAgent.shutdown(); consola.log(""); consola.box({ title: "Forum bootstrap complete!", message: [ "Next steps:", " 1. Start the appview: pnpm --filter @atbb/appview dev", " 2. Start the web UI: pnpm --filter @atbb/web dev", ` 3. Log in as ${ownerInput} to access admin features`, " 4. Create categories and boards from the admin panel", ].join("\n"), }); }, }); ``` **Step 3: Update CLI entrypoint** Replace `packages/cli/src/index.ts`: ```typescript #!/usr/bin/env node import { defineCommand, runMain } from "citty"; import { initCommand } from "./commands/init.js"; const main = defineCommand({ meta: { name: "atbb", version: "0.1.0", description: "atBB Forum management CLI", }, subCommands: { init: initCommand, }, }); runMain(main); ``` **Step 4: Verify build** Run: `pnpm --filter @atbb/cli build` Expected: Clean build. **Step 5: Verify help output** Run: `node packages/cli/dist/index.js init --help` Expected: Shows init command help with `--forum-name`, `--forum-description`, `--owner` args. **Step 6: Commit** ```bash git add packages/cli/ git commit -m "feat(cli): wire up init command with interactive prompts and flag overrides" ``` --- ## Task 11: Update Dockerfile and turbo config **Files:** - Modify: `Dockerfile` - Modify: `turbo.json` (add env vars for CLI if needed) **Step 1: Update Dockerfile builder stage** In the builder stage, add the new packages to the COPY commands. After the existing package.json COPY lines, add: ```dockerfile COPY packages/atproto/package.json ./packages/atproto/ COPY packages/cli/package.json ./packages/cli/ ``` **Step 2: Update Dockerfile runtime stage** In the runtime stage, add: ```dockerfile # Copy package files for production install COPY packages/atproto/package.json ./packages/atproto/ COPY packages/cli/package.json ./packages/cli/ # Copy built artifacts from builder stage (add these after existing COPY --from=builder lines) COPY --from=builder /build/packages/atproto/dist ./packages/atproto/dist COPY --from=builder /build/packages/cli/dist ./packages/cli/dist ``` **Step 3: Verify Docker build** Run: `docker build -t atbb:test .` Expected: Build succeeds. If Docker is not available locally, verify by checking the Dockerfile is syntactically correct and commit — CI will catch Docker build issues. **Step 4: Commit** ```bash git add Dockerfile git commit -m "build: add @atbb/atproto and @atbb/cli to Docker image" ``` --- ## Task 12: Full integration test and final verification **Step 1: Run the full build** Run: `pnpm build` Expected: All packages build successfully. Turbo handles dependency ordering. **Step 2: Run all tests** Run: `pnpm test` Expected: All tests pass across all packages. **Step 3: Run lint** Run: `pnpm lint` Expected: No type errors. **Step 4: Verify CLI end-to-end (dry run)** Run: `node packages/cli/dist/index.js init --help` Expected: Shows help text. Run: `node packages/cli/dist/index.js init` (without .env, expect graceful failure) Expected: "Missing required environment variables" error with list. **Step 5: Final commit if any cleanup needed** If any adjustments were made during verification: ```bash git add -A git commit -m "chore: cleanup after integration verification" ```