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

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

{
  "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

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*.ts"]
}

Step 3: Create empty index.ts

// @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

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:

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:

/**
 * 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

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:

"@atbb/atproto": "workspace:*"

Then run: pnpm install

Replace apps/appview/src/lib/errors.ts with a re-export shim:

// 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

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.tspackages/atproto/src/forum-agent.ts.

The only change: replace the local isAuthError / isNetworkError helper functions at the top of the file with an import:

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.tspackages/atproto/src/__tests__/forum-agent.test.ts.

Only change the import path:

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

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:

// 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

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:

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:

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<ResolvedIdentity> {
  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:

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

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

{
  "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

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*.ts"]
}

Step 3: Create minimal CLI entrypoint

Create packages/cli/src/index.ts:

#!/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

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:

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:

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:

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:

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

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:

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<string, any> = {}) {
    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:

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<CreateForumResult> {
  // 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

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:

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:

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<SeedRolesResult> {
  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

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:

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:

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<AssignOwnerResult> {
  // 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

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:

"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:

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:

#!/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

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:

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:

# 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

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:

git add -A
git commit -m "chore: cleanup after integration verification"