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.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:
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:
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.tsapps/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/promptsto 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"