CLI: Categories and Boards — Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Extend @atbb/cli with atbb category add and atbb board add commands, plus a seeding step in atbb init that optionally creates a starter category and board.
Architecture: Two new step modules (create-category.ts, create-board.ts) follow the exact pattern of create-forum.ts — check idempotency, write PDS record, insert DB row. Two new command files (category.ts, board.ts) expose atbb category add and atbb board add using citty's nested subcommand structure. init.ts gains a Step 4 that calls these step functions with interactive prompts.
Tech Stack: TypeScript, citty (CLI routing), consola (output), @inquirer/prompts (interactive input), Drizzle ORM (DB), @atproto/api (PDS writes)
Task 1: create-category.ts step module (TDD)#
Files:
- Create:
packages/cli/src/__tests__/create-category.test.ts - Create:
packages/cli/src/lib/steps/create-category.ts
Step 1: Write the failing tests#
Create packages/cli/src/__tests__/create-category.test.ts:
import { describe, it, expect, vi } from "vitest";
import { createCategory } from "../lib/steps/create-category.js";
describe("createCategory", () => {
const forumDid = "did:plc:testforum";
// Builds a mock DB. If existingCategory is set, the first select() returns it.
// The second select() (forum lookup) always returns a mock forum row.
function mockDb(options: { existingCategory?: any } = {}) {
let callCount = 0;
return {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
// First select: category idempotency check
return options.existingCategory ? [options.existingCategory] : [];
}
// Second select: forum lookup for forumId
return [{ id: BigInt(1) }];
}),
}),
}),
})),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockResolvedValue(undefined),
}),
} as any;
}
function mockAgent(overrides: Record<string, any> = {}) {
return {
com: {
atproto: {
repo: {
createRecord: vi.fn().mockResolvedValue({
data: {
uri: `at://${forumDid}/space.atbb.forum.category/tid123`,
cid: "bafytest",
},
}),
...overrides,
},
},
},
} as any;
}
it("creates category on PDS and inserts into DB", async () => {
const db = mockDb();
const agent = mockAgent();
const result = await createCategory(db, agent, forumDid, {
name: "General",
description: "General discussion",
});
expect(result.created).toBe(true);
expect(result.skipped).toBe(false);
expect(result.uri).toContain("space.atbb.forum.category/tid123");
expect(result.cid).toBe("bafytest");
expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
expect.objectContaining({
repo: forumDid,
collection: "space.atbb.forum.category",
record: expect.objectContaining({
$type: "space.atbb.forum.category",
name: "General",
description: "General discussion",
}),
})
);
expect(db.insert).toHaveBeenCalled();
});
it("derives slug from name when not provided", async () => {
const db = mockDb();
const agent = mockAgent();
await createCategory(db, agent, forumDid, { name: "My Cool Category" });
expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
expect.objectContaining({
record: expect.objectContaining({ slug: "my-cool-category" }),
})
);
});
it("uses provided slug instead of deriving one", async () => {
const db = mockDb();
const agent = mockAgent();
await createCategory(db, agent, forumDid, { name: "General", slug: "gen" });
expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
expect.objectContaining({
record: expect.objectContaining({ slug: "gen" }),
})
);
});
it("skips when category with same name already exists", async () => {
const db = mockDb({
existingCategory: {
did: forumDid,
rkey: "existingtid",
cid: "bafyexisting",
name: "General",
},
});
const agent = mockAgent();
const result = await createCategory(db, agent, forumDid, { name: "General" });
expect(result.created).toBe(false);
expect(result.skipped).toBe(true);
expect(result.existingName).toBe("General");
expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled();
expect(db.insert).not.toHaveBeenCalled();
});
it("throws when PDS write fails", async () => {
const db = mockDb();
const agent = mockAgent({
createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")),
});
await expect(
createCategory(db, agent, forumDid, { name: "General" })
).rejects.toThrow("PDS write failed");
});
});
Step 2: Run tests to verify they fail#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts
Expected: FAIL with "Cannot find module '../lib/steps/create-category.js'"
Step 3: Implement create-category.ts#
Create packages/cli/src/lib/steps/create-category.ts:
import type { AtpAgent } from "@atproto/api";
import type { Database } from "@atbb/db";
import { categories, forums } from "@atbb/db";
import { eq, and } from "drizzle-orm";
import { isProgrammingError } from "@atbb/atproto";
interface CreateCategoryInput {
name: string;
description?: string;
slug?: string;
sortOrder?: number;
}
interface CreateCategoryResult {
created: boolean;
skipped: boolean;
uri?: string;
cid?: string;
existingName?: string;
}
function deriveSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Create a space.atbb.forum.category record on the Forum DID's PDS
* and insert it into the database.
* Idempotent: skips if a category with the same name already exists.
*/
export async function createCategory(
db: Database,
agent: AtpAgent,
forumDid: string,
input: CreateCategoryInput
): Promise<CreateCategoryResult> {
// Check if category with this name already exists
const [existing] = await db
.select()
.from(categories)
.where(and(eq(categories.did, forumDid), eq(categories.name, input.name)))
.limit(1);
if (existing) {
return {
created: false,
skipped: true,
uri: `at://${existing.did}/space.atbb.forum.category/${existing.rkey}`,
cid: existing.cid,
existingName: existing.name,
};
}
// Look up forum row for FK reference (optional — null if forum not yet in DB)
const [forum] = await db
.select()
.from(forums)
.where(and(eq(forums.did, forumDid), eq(forums.rkey, "self")))
.limit(1);
const slug = input.slug ?? deriveSlug(input.name);
const now = new Date();
let response;
try {
response = await agent.com.atproto.repo.createRecord({
repo: forumDid,
collection: "space.atbb.forum.category",
record: {
$type: "space.atbb.forum.category",
name: input.name,
...(input.description && { description: input.description }),
slug,
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
createdAt: now.toISOString(),
},
});
} catch (error) {
if (isProgrammingError(error)) throw error;
throw error; // PDS errors bubble up to command handler
}
const rkey = response.data.uri.split("/").pop()!;
await db.insert(categories).values({
did: forumDid,
rkey,
cid: response.data.cid,
name: input.name,
description: input.description ?? null,
slug,
sortOrder: input.sortOrder ?? null,
forumId: forum?.id ?? null,
createdAt: now,
indexedAt: now,
});
return {
created: true,
skipped: false,
uri: response.data.uri,
cid: response.data.cid,
};
}
Step 4: Run tests to verify they pass#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli exec vitest run src/__tests__/create-category.test.ts
Expected: All 5 tests PASS
Step 5: Commit#
git add packages/cli/src/__tests__/create-category.test.ts packages/cli/src/lib/steps/create-category.ts
git commit -m "feat: add createCategory step module (ATB-28)"
Task 2: create-board.ts step module (TDD)#
Files:
- Create:
packages/cli/src/__tests__/create-board.test.ts - Create:
packages/cli/src/lib/steps/create-board.ts
Step 1: Write the failing tests#
Create packages/cli/src/__tests__/create-board.test.ts:
import { describe, it, expect, vi } from "vitest";
import { createBoard } from "../lib/steps/create-board.js";
describe("createBoard", () => {
const forumDid = "did:plc:testforum";
const categoryUri = `at://${forumDid}/space.atbb.forum.category/cattid`;
const categoryId = BigInt(42);
const categoryCid = "bafycategory";
function mockDb(options: { existingBoard?: any } = {}) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(
options.existingBoard ? [options.existingBoard] : []
),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockResolvedValue(undefined),
}),
} as any;
}
function mockAgent(overrides: Record<string, any> = {}) {
return {
com: {
atproto: {
repo: {
createRecord: vi.fn().mockResolvedValue({
data: {
uri: `at://${forumDid}/space.atbb.forum.board/tid456`,
cid: "bafyboard",
},
}),
...overrides,
},
},
},
} as any;
}
const baseInput = {
name: "General Discussion",
categoryUri,
categoryId,
categoryCid,
};
it("creates board on PDS and inserts into DB", async () => {
const db = mockDb();
const agent = mockAgent();
const result = await createBoard(db, agent, forumDid, baseInput);
expect(result.created).toBe(true);
expect(result.skipped).toBe(false);
expect(result.uri).toContain("space.atbb.forum.board/tid456");
expect(result.cid).toBe("bafyboard");
expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
expect.objectContaining({
repo: forumDid,
collection: "space.atbb.forum.board",
record: expect.objectContaining({
$type: "space.atbb.forum.board",
name: "General Discussion",
// Board record includes the category ref nested under "category"
category: {
category: { uri: categoryUri, cid: categoryCid },
},
}),
})
);
expect(db.insert).toHaveBeenCalled();
});
it("derives slug from name", async () => {
const db = mockDb();
const agent = mockAgent();
await createBoard(db, agent, forumDid, {
...baseInput,
name: "Off Topic Chat",
});
expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith(
expect.objectContaining({
record: expect.objectContaining({ slug: "off-topic-chat" }),
})
);
});
it("skips when board with same name exists in the same category", async () => {
const db = mockDb({
existingBoard: {
did: forumDid,
rkey: "existingtid",
cid: "bafyexisting",
name: "General Discussion",
},
});
const agent = mockAgent();
const result = await createBoard(db, agent, forumDid, baseInput);
expect(result.created).toBe(false);
expect(result.skipped).toBe(true);
expect(result.existingName).toBe("General Discussion");
expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled();
expect(db.insert).not.toHaveBeenCalled();
});
it("throws when PDS write fails", async () => {
const db = mockDb();
const agent = mockAgent({
createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")),
});
await expect(createBoard(db, agent, forumDid, baseInput)).rejects.toThrow(
"PDS write failed"
);
});
});
Step 2: Run tests to verify they fail#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts
Expected: FAIL with "Cannot find module '../lib/steps/create-board.js'"
Step 3: Implement create-board.ts#
Create packages/cli/src/lib/steps/create-board.ts:
import type { AtpAgent } from "@atproto/api";
import type { Database } from "@atbb/db";
import { boards } from "@atbb/db";
import { eq, and } from "drizzle-orm";
import { isProgrammingError } from "@atbb/atproto";
interface CreateBoardInput {
name: string;
description?: string;
slug?: string;
sortOrder?: number;
categoryUri: string; // AT URI: at://did/space.atbb.forum.category/rkey
categoryId: bigint; // DB FK
categoryCid: string; // CID for the category strongRef
}
interface CreateBoardResult {
created: boolean;
skipped: boolean;
uri?: string;
cid?: string;
existingName?: string;
}
function deriveSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Create a space.atbb.forum.board record on the Forum DID's PDS
* and insert it into the database.
* Idempotent: skips if a board with the same name in the same category exists.
*/
export async function createBoard(
db: Database,
agent: AtpAgent,
forumDid: string,
input: CreateBoardInput
): Promise<CreateBoardResult> {
// Check if board with this name already exists in the category
const [existing] = await db
.select()
.from(boards)
.where(
and(
eq(boards.did, forumDid),
eq(boards.name, input.name),
eq(boards.categoryUri, input.categoryUri)
)
)
.limit(1);
if (existing) {
return {
created: false,
skipped: true,
uri: `at://${existing.did}/space.atbb.forum.board/${existing.rkey}`,
cid: existing.cid,
existingName: existing.name,
};
}
const slug = input.slug ?? deriveSlug(input.name);
const now = new Date();
let response;
try {
response = await agent.com.atproto.repo.createRecord({
repo: forumDid,
collection: "space.atbb.forum.board",
record: {
$type: "space.atbb.forum.board",
name: input.name,
...(input.description && { description: input.description }),
slug,
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
// categoryRef shape: { category: strongRef }
category: {
category: {
uri: input.categoryUri,
cid: input.categoryCid,
},
},
createdAt: now.toISOString(),
},
});
} catch (error) {
if (isProgrammingError(error)) throw error;
throw error;
}
const rkey = response.data.uri.split("/").pop()!;
await db.insert(boards).values({
did: forumDid,
rkey,
cid: response.data.cid,
name: input.name,
description: input.description ?? null,
slug,
sortOrder: input.sortOrder ?? null,
categoryId: input.categoryId,
categoryUri: input.categoryUri,
createdAt: now,
indexedAt: now,
});
return {
created: true,
skipped: false,
uri: response.data.uri,
cid: response.data.cid,
};
}
Step 4: Run tests to verify they pass#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli exec vitest run src/__tests__/create-board.test.ts
Expected: All 4 tests PASS
Step 5: Commit#
git add packages/cli/src/__tests__/create-board.test.ts packages/cli/src/lib/steps/create-board.ts
git commit -m "feat: add createBoard step module (ATB-28)"
Task 3: atbb category add command#
Files:
- Create:
packages/cli/src/commands/category.ts
Step 1: Implement category.ts#
Create packages/cli/src/commands/category.ts:
import { defineCommand } from "citty";
import consola from "consola";
import { input } from "@inquirer/prompts";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "@atbb/db";
import { ForumAgent } from "@atbb/atproto";
import { loadCliConfig } from "../lib/config.js";
import { checkEnvironment } from "../lib/preflight.js";
import { createCategory } from "../lib/steps/create-category.js";
const categoryAddCommand = defineCommand({
meta: {
name: "add",
description: "Add a new category to the forum",
},
args: {
name: {
type: "string",
description: "Category name",
},
description: {
type: "string",
description: "Category description (optional)",
},
slug: {
type: "string",
description: "URL-friendly identifier (auto-derived from name if omitted)",
},
"sort-order": {
type: "string",
description: "Numeric sort position — lower values appear first",
},
},
async run({ args }) {
consola.box("atBB — Add Category");
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);
}
const sql = postgres(config.databaseUrl);
const db = drizzle(sql, { schema });
async function cleanup() {
await sql.end();
}
try {
await sql`SELECT 1`;
consola.success("Database connection successful");
} catch (error) {
consola.error(
"Failed to connect to database:",
error instanceof Error ? error.message : String(error)
);
await cleanup();
process.exit(1);
}
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}`);
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
const agent = forumAgent.getAgent()!;
consola.success(`Authenticated as ${config.forumHandle}`);
const name =
args.name ??
(await input({ message: "Category name:", default: "General" }));
const description =
args.description ??
(await input({ message: "Category description (optional):" }));
const sortOrderRaw = args["sort-order"];
const sortOrder =
sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined;
try {
const result = await createCategory(db, agent, config.forumDid, {
name,
...(description && { description }),
...(args.slug && { slug: args.slug }),
...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }),
});
if (result.skipped) {
consola.warn(`Category "${result.existingName}" already exists: ${result.uri}`);
} else {
consola.success(`Created category "${name}"`);
consola.info(`URI: ${result.uri}`);
}
} catch (error) {
consola.error(
"Failed to create category:",
error instanceof Error ? error.message : String(error)
);
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
await forumAgent.shutdown();
await cleanup();
},
});
export const categoryCommand = defineCommand({
meta: {
name: "category",
description: "Manage forum categories",
},
subCommands: {
add: categoryAddCommand,
},
});
Step 2: Register categoryCommand in index.ts#
Open packages/cli/src/index.ts and add the import + subcommand entry:
// Add this import (after existing imports):
import { categoryCommand } from "./commands/category.js";
// Update subCommands:
subCommands: {
init: initCommand,
category: categoryCommand, // ← add this line
},
Step 3: Build to verify no TypeScript errors#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli lint
Expected: No errors
Step 4: Commit#
git add packages/cli/src/commands/category.ts packages/cli/src/index.ts
git commit -m "feat: add atbb category add command (ATB-28)"
Task 4: atbb board add command#
Files:
- Create:
packages/cli/src/commands/board.ts - Modify:
packages/cli/src/index.ts
Step 1: Implement board.ts#
Create packages/cli/src/commands/board.ts:
import { defineCommand } from "citty";
import consola from "consola";
import { input, select } from "@inquirer/prompts";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "@atbb/db";
import { categories } from "@atbb/db";
import { eq, and } from "drizzle-orm";
import { ForumAgent } from "@atbb/atproto";
import { loadCliConfig } from "../lib/config.js";
import { checkEnvironment } from "../lib/preflight.js";
import { createBoard } from "../lib/steps/create-board.js";
const boardAddCommand = defineCommand({
meta: {
name: "add",
description: "Add a new board within a category",
},
args: {
"category-uri": {
type: "string",
description: "AT URI of the parent category (e.g. at://did/space.atbb.forum.category/rkey)",
},
name: {
type: "string",
description: "Board name",
},
description: {
type: "string",
description: "Board description (optional)",
},
slug: {
type: "string",
description: "URL-friendly identifier (auto-derived from name if omitted)",
},
"sort-order": {
type: "string",
description: "Numeric sort position — lower values appear first",
},
},
async run({ args }) {
consola.box("atBB — Add Board");
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);
}
const sql = postgres(config.databaseUrl);
const db = drizzle(sql, { schema });
async function cleanup() {
await sql.end();
}
try {
await sql`SELECT 1`;
consola.success("Database connection successful");
} catch (error) {
consola.error(
"Failed to connect to database:",
error instanceof Error ? error.message : String(error)
);
await cleanup();
process.exit(1);
}
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}`);
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
const agent = forumAgent.getAgent()!;
consola.success(`Authenticated as ${config.forumHandle}`);
// Resolve parent category
let categoryUri: string;
let categoryId: bigint;
let categoryCid: string;
if (args["category-uri"]) {
// Validate by looking it up in the DB
// Parse AT URI: at://{did}/{collection}/{rkey}
const parts = args["category-uri"].split("/");
const did = parts[2];
const rkey = parts[parts.length - 1];
const [found] = await db
.select()
.from(categories)
.where(and(eq(categories.did, did), eq(categories.rkey, rkey)))
.limit(1);
if (!found) {
consola.error(`Category not found: ${args["category-uri"]}`);
consola.info("Create it first with: atbb category add");
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
categoryUri = args["category-uri"];
categoryId = found.id;
categoryCid = found.cid;
} else {
// Interactive selection from all categories in the forum
const allCategories = await db
.select()
.from(categories)
.where(eq(categories.did, config.forumDid))
.limit(100);
if (allCategories.length === 0) {
consola.error("No categories found in the database.");
consola.info("Create one first with: atbb category add");
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
const chosen = await select({
message: "Select parent category:",
choices: allCategories.map((c) => ({
name: c.description ? `${c.name} — ${c.description}` : c.name,
value: c,
})),
});
categoryUri = `at://${chosen.did}/space.atbb.forum.category/${chosen.rkey}`;
categoryId = chosen.id;
categoryCid = chosen.cid;
}
const name =
args.name ??
(await input({ message: "Board name:", default: "General Discussion" }));
const description =
args.description ??
(await input({ message: "Board description (optional):" }));
const sortOrderRaw = args["sort-order"];
const sortOrder =
sortOrderRaw !== undefined ? parseInt(sortOrderRaw, 10) : undefined;
try {
const result = await createBoard(db, agent, config.forumDid, {
name,
...(description && { description }),
...(args.slug && { slug: args.slug }),
...(sortOrder !== undefined && !isNaN(sortOrder) && { sortOrder }),
categoryUri,
categoryId,
categoryCid,
});
if (result.skipped) {
consola.warn(`Board "${result.existingName}" already exists: ${result.uri}`);
} else {
consola.success(`Created board "${name}"`);
consola.info(`URI: ${result.uri}`);
}
} catch (error) {
consola.error(
"Failed to create board:",
error instanceof Error ? error.message : String(error)
);
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
await forumAgent.shutdown();
await cleanup();
},
});
export const boardCommand = defineCommand({
meta: {
name: "board",
description: "Manage forum boards",
},
subCommands: {
add: boardAddCommand,
},
});
Step 2: Register boardCommand in index.ts#
Add to packages/cli/src/index.ts:
// Add import (after categoryCommand import):
import { boardCommand } from "./commands/board.js";
// Update subCommands:
subCommands: {
init: initCommand,
category: categoryCommand,
board: boardCommand, // ← add this line
},
Step 3: Build to verify no TypeScript errors#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli lint
Expected: No errors
Step 4: Commit#
git add packages/cli/src/commands/board.ts packages/cli/src/index.ts
git commit -m "feat: add atbb board add command (ATB-28)"
Task 5: Extend init with Step 4 — seed initial structure#
Files:
- Modify:
packages/cli/src/commands/init.ts
Step 1: Add imports for new step functions and confirm prompt#
At the top of packages/cli/src/commands/init.ts, add:
import { confirm } from "@inquirer/prompts";
import { createCategory } from "../lib/steps/create-category.js";
import { createBoard } from "../lib/steps/create-board.js";
Step 2: Add Step 4 to the run() function#
Locate the end of Step 3 (the assignOwnerRole try-catch block that ends around line 176), then add before the cleanup/success box block:
// Step 6: Seed initial categories and boards (optional)
consola.log("");
consola.info("Step 4: Seed Initial Structure");
const shouldSeed = await confirm({
message: "Seed an initial category and board?",
default: true,
});
if (shouldSeed) {
const categoryName = await input({
message: "Category name:",
default: "General",
});
const categoryDescription = await input({
message: "Category description (optional):",
});
let categoryUri: string | undefined;
let categoryId: bigint | undefined;
let categoryCid: string | undefined;
try {
const categoryResult = await createCategory(db, agent, config.forumDid, {
name: categoryName,
...(categoryDescription && { description: categoryDescription }),
});
if (categoryResult.skipped) {
consola.warn(`Category "${categoryResult.existingName}" already exists`);
} else {
consola.success(`Created category "${categoryName}": ${categoryResult.uri}`);
}
categoryUri = categoryResult.uri;
categoryCid = categoryResult.cid;
// Look up the categoryId from DB (needed for board FK)
const { categories } = await import("@atbb/db");
const { eq, and } = await import("drizzle-orm");
const parts = categoryUri!.split("/");
const rkey = parts[parts.length - 1];
const [cat] = await db
.select()
.from(categories)
.where(and(eq(categories.did, config.forumDid), eq(categories.rkey, rkey)))
.limit(1);
categoryId = cat?.id;
} catch (error) {
consola.error("Failed to create category:", error instanceof Error ? error.message : String(error));
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
if (categoryUri && categoryId && categoryCid) {
const boardName = await input({
message: "Board name:",
default: "General Discussion",
});
const boardDescription = await input({
message: "Board description (optional):",
});
try {
const boardResult = await createBoard(db, agent, config.forumDid, {
name: boardName,
...(boardDescription && { description: boardDescription }),
categoryUri,
categoryId,
categoryCid,
});
if (boardResult.skipped) {
consola.warn(`Board "${boardResult.existingName}" already exists`);
} else {
consola.success(`Created board "${boardName}": ${boardResult.uri}`);
}
} catch (error) {
consola.error("Failed to create board:", error instanceof Error ? error.message : String(error));
await forumAgent.shutdown();
await cleanup();
process.exit(1);
}
}
} else {
consola.info("Skipped. Add categories later with: atbb category add");
}
Note on the dynamic imports above: The cleaner approach is to move the categories and drizzle-orm imports to the top of the file alongside the existing imports. Specifically add to the top of init.ts:
import { categories } from "@atbb/db";
import { eq, and } from "drizzle-orm";
import { confirm } from "@inquirer/prompts";
import { createCategory } from "../lib/steps/create-category.js";
import { createBoard } from "../lib/steps/create-board.js";
And replace the dynamic import block in Step 4 with direct references to the top-level imports.
Step 3: Update the success message#
Find the consola.box success message at the end of init.ts. Update the "Next steps" message to remove the "Create categories and boards" note (they've been created):
Replace the message array with:
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. Add more boards: atbb board add",
" 5. Add more categories: atbb category add",
].join("\n"),
Step 4: Build to verify no TypeScript errors#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli lint
Expected: No errors
Step 5: Run all CLI tests#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm --filter @atbb/cli exec vitest run
Expected: All tests PASS (existing tests unaffected, new tests pass)
Step 6: Commit#
git add packages/cli/src/commands/init.ts
git commit -m "feat: extend init with optional category/board seeding step (ATB-28)"
Task 6: Final verification#
Step 1: Run all tests across the monorepo#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm test
Expected: All tests PASS
Step 2: Typecheck all packages#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm turbo lint
Expected: No TypeScript errors
Step 3: Build#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
pnpm build
Expected: Build succeeds
Step 4: Smoke test the CLI (optional, requires running database)#
export PATH="/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH"
# Verify new commands appear in help
pnpm --filter @atbb/cli dev -- category --help
pnpm --filter @atbb/cli dev -- board --help
pnpm --filter @atbb/cli dev -- category add --help
pnpm --filter @atbb/cli dev -- board add --help
Expected: Help text showing flags for each command
Summary of New Files#
| File | Purpose |
|---|---|
packages/cli/src/lib/steps/create-category.ts |
Step module — idempotent PDS + DB write for categories |
packages/cli/src/lib/steps/create-board.ts |
Step module — idempotent PDS + DB write for boards |
packages/cli/src/commands/category.ts |
atbb category add command |
packages/cli/src/commands/board.ts |
atbb board add command with interactive category selection |
packages/cli/src/__tests__/create-category.test.ts |
Tests for createCategory step |
packages/cli/src/__tests__/create-board.test.ts |
Tests for createBoard step |
Modified Files#
| File | Change |
|---|---|
packages/cli/src/index.ts |
Register category and board subcommands |
packages/cli/src/commands/init.ts |
Add Step 4: optional category/board seeding |