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

feat: bootstrap CLI and shared @atbb/atproto package (#41)

* docs: bootstrap CLI design for first-time forum setup

Adds design document for `atbb init` CLI command that automates
forum bootstrapping — creating the forum record on the PDS, seeding
default roles, and assigning the first Owner. Includes extraction
of ForumAgent into shared `packages/atproto` package.

* docs: bootstrap CLI implementation plan

12-task TDD implementation plan for atbb init command. Covers
packages/atproto extraction, packages/cli scaffolding, bootstrap
steps (create-forum, seed-roles, assign-owner), and Dockerfile updates.

* feat: extract @atbb/atproto shared package with error helpers

Create packages/atproto as a shared AT Protocol utilities package.
Extract error classification helpers (isProgrammingError, isNetworkError,
isAuthError, isDatabaseError) from appview into the shared package,
consolidating patterns from errors.ts and forum-agent.ts. The appview
errors.ts becomes a re-export shim for backward compatibility.

* refactor: move ForumAgent to @atbb/atproto shared package

Move ForumAgent class and tests from appview to packages/atproto.
Replace inline isAuthError/isNetworkError with imports from errors.ts.
Add ETIMEDOUT pattern to isNetworkError for Node.js socket errors.
Update app-context.ts import and app-context test mock path.

* feat: add identity resolution helper to @atbb/atproto

Add resolveIdentity() that accepts either a DID (returns as-is) or a
handle (resolves via PDS resolveHandle). Used by the CLI to let
operators specify the forum owner by handle or DID.

* chore: scaffold @atbb/cli package with citty

* feat(cli): add config loader and preflight environment checks

* feat(cli): implement create-forum bootstrap step

* feat(cli): implement seed-roles bootstrap step

* feat(cli): implement assign-owner bootstrap step

* feat(cli): wire up init command with interactive prompts and flag overrides

* chore: update Dockerfile to include atproto and cli packages

* fix(cli): make config test hermetic for CI environment

Explicitly stub env vars to empty strings instead of relying on a clean
environment. CI sets DATABASE_URL for its PostgreSQL service container,
so vi.unstubAllEnvs() alone is insufficient — it only reverses previous
stubs, not real env vars.

* fix(cli): address code review feedback on bootstrap flow

1. Fix bootstrap ordering: seedDefaultRoles now inserts into DB after
PDS write, so assignOwnerRole can find the Owner role immediately
without waiting for the firehose.

2. Fix membership ownership: assignOwnerRole no longer writes to the
forum DID's PDS repo (wrong owner per data model). Instead, inserts
membership directly into DB. The PDS record will be created when the
user logs in via OAuth.

3. Fix bare catch in createForumRecord: now discriminates RecordNotFound
from network/auth/programming errors. Only proceeds to create when
the record genuinely doesn't exist.

4. Remove fabricated cid:"pending": no longer writes PDS records with
fake CIDs. Direct DB inserts use "bootstrap" sentinel to indicate
CLI-created records.

5. Fix DB connection leak: init command creates postgres client directly
and calls sql.end() on all exit paths (success + error).

Also: createForumRecord now inserts forum into DB after PDS write,
ensuring downstream steps can reference it.

* fix: upgrade bootstrap memberships to real PDS records on first login

When the CLI creates a bootstrap membership (cid="bootstrap"), the
appview now detects it on first OAuth login and upgrades it by writing
a real PDS record to the user's repo, then updating the DB row with
the actual rkey/cid while preserving the roleUri.

authored by

Malpercio and committed by
GitHub
8dacd21a 40cb3e58

+1812 -96
+6
Dockerfile
··· 15 15 COPY apps/appview/package.json ./apps/appview/ 16 16 COPY apps/web/package.json ./apps/web/ 17 17 COPY packages/db/package.json ./packages/db/ 18 + COPY packages/atproto/package.json ./packages/atproto/ 19 + COPY packages/cli/package.json ./packages/cli/ 18 20 COPY packages/lexicon/package.json ./packages/lexicon/ 19 21 20 22 # Install all dependencies (including dev dependencies for build) ··· 41 43 COPY apps/appview/package.json ./apps/appview/ 42 44 COPY apps/web/package.json ./apps/web/ 43 45 COPY packages/db/package.json ./packages/db/ 46 + COPY packages/atproto/package.json ./packages/atproto/ 47 + COPY packages/cli/package.json ./packages/cli/ 44 48 COPY packages/lexicon/package.json ./packages/lexicon/ 45 49 46 50 # Install pnpm and production dependencies only ··· 61 65 COPY --from=builder /build/apps/appview/dist ./apps/appview/dist 62 66 COPY --from=builder /build/apps/web/dist ./apps/web/dist 63 67 COPY --from=builder /build/packages/db/dist ./packages/db/dist 68 + COPY --from=builder /build/packages/atproto/dist ./packages/atproto/dist 69 + COPY --from=builder /build/packages/cli/dist ./packages/cli/dist 64 70 COPY --from=builder /build/packages/lexicon/dist ./packages/lexicon/dist 65 71 66 72 # Copy migration files for drizzle-kit
+1
apps/appview/package.json
··· 15 15 "db:migrate": "drizzle-kit migrate" 16 16 }, 17 17 "dependencies": { 18 + "@atbb/atproto": "workspace:*", 18 19 "@atbb/db": "workspace:*", 19 20 "@atbb/lexicon": "workspace:*", 20 21 "@atproto/api": "^0.15.0",
+1 -1
apps/appview/src/lib/__tests__/app-context.test.ts
··· 29 29 CookieSessionStore: vi.fn(() => ({ destroy: vi.fn() })), 30 30 })); 31 31 32 - vi.mock("../forum-agent.js", () => ({ 32 + vi.mock("@atbb/atproto", () => ({ 33 33 ForumAgent: vi.fn(() => ({ 34 34 initialize: vi.fn(), 35 35 shutdown: vi.fn(),
+1 -1
apps/appview/src/lib/__tests__/forum-agent.test.ts packages/atproto/src/__tests__/forum-agent.test.ts
··· 18 18 login: mockLogin, 19 19 session: null, 20 20 }; 21 - (AtpAgent as any).mockImplementation(() => mockAgent); 21 + (AtpAgent as any).mockImplementation(function () { return mockAgent; }); 22 22 }); 23 23 24 24 afterEach(async () => {
+66 -1
apps/appview/src/lib/__tests__/membership.test.ts
··· 2 2 import { createMembershipForUser } from "../membership.js"; 3 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 4 import { memberships, users, forums } from "@atbb/db"; 5 - import { eq } from "drizzle-orm"; 5 + import { eq, and } from "drizzle-orm"; 6 6 7 7 describe("createMembershipForUser", () => { 8 8 let ctx: TestContext; ··· 211 211 testDid 212 212 ); 213 213 expect(result2.created).toBe(false); 214 + }); 215 + 216 + it("upgrades bootstrap membership to real PDS record", async () => { 217 + const testDid = `did:plc:test-bootstrap-${Date.now()}`; 218 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 219 + const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/ownerrkey`; 220 + 221 + const mockAgent = { 222 + com: { 223 + atproto: { 224 + repo: { 225 + putRecord: vi.fn().mockResolvedValue({ 226 + data: { 227 + uri: `at://${testDid}/space.atbb.membership/tid456`, 228 + cid: "bafyupgraded789", 229 + }, 230 + }), 231 + }, 232 + }, 233 + }, 234 + } as any; 235 + 236 + // Insert user (FK constraint) 237 + await ctx.db.insert(users).values({ 238 + did: testDid, 239 + handle: "bootstrap.owner", 240 + indexedAt: new Date(), 241 + }); 242 + 243 + // Insert bootstrap membership (as created by `atbb init`) 244 + await ctx.db.insert(memberships).values({ 245 + did: testDid, 246 + rkey: "bootstrap", 247 + cid: "bootstrap", 248 + forumUri, 249 + roleUri: ownerRoleUri, 250 + role: "Owner", 251 + createdAt: new Date(), 252 + indexedAt: new Date(), 253 + }); 254 + 255 + const result = await createMembershipForUser(ctx, mockAgent, testDid); 256 + 257 + // Should create a real PDS record 258 + expect(result.created).toBe(true); 259 + expect(result.cid).toBe("bafyupgraded789"); 260 + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( 261 + expect.objectContaining({ 262 + repo: testDid, 263 + collection: "space.atbb.membership", 264 + }) 265 + ); 266 + 267 + // Verify DB row was upgraded with real values 268 + const [updated] = await ctx.db 269 + .select() 270 + .from(memberships) 271 + .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 272 + .limit(1); 273 + 274 + expect(updated.cid).toBe("bafyupgraded789"); 275 + expect(updated.rkey).not.toBe("bootstrap"); 276 + // Role preserved through the upgrade 277 + expect(updated.roleUri).toBe(ownerRoleUri); 278 + expect(updated.role).toBe("Owner"); 214 279 }); 215 280 });
+1 -1
apps/appview/src/lib/app-context.ts
··· 4 4 import { NodeOAuthClient } from "@atproto/oauth-client-node"; 5 5 import { OAuthStateStore, OAuthSessionStore } from "./oauth-stores.js"; 6 6 import { CookieSessionStore } from "./cookie-session-store.js"; 7 - import { ForumAgent } from "./forum-agent.js"; 7 + import { ForumAgent } from "@atbb/atproto"; 8 8 import type { AppConfig } from "./config.js"; 9 9 10 10 /**
+8 -51
apps/appview/src/lib/errors.ts
··· 1 - /** 2 - * Error classification helpers for consistent error handling. 3 - */ 4 - 5 - /** 6 - * Check if an error is a programming error (code bug). 7 - * Programming errors should be re-thrown, not caught. 8 - */ 9 - export function isProgrammingError(error: unknown): boolean { 10 - return ( 11 - error instanceof TypeError || 12 - error instanceof ReferenceError || 13 - error instanceof SyntaxError 14 - ); 15 - } 16 - 17 - /** 18 - * Check if an error is a network error (temporary). 19 - * Network errors should return 503 (retry later). 20 - */ 21 - export function isNetworkError(error: unknown): boolean { 22 - if (!(error instanceof Error)) return false; 23 - const msg = error.message.toLowerCase(); 24 - return ( 25 - msg.includes("fetch failed") || 26 - msg.includes("network") || 27 - msg.includes("econnrefused") || 28 - msg.includes("enotfound") || 29 - msg.includes("timeout") 30 - ); 31 - } 32 - 33 - /** 34 - * Check if an error represents a database-layer failure 35 - * (pool exhausted, postgres-specific errors, query failures). 36 - * These errors indicate temporary unavailability - user should retry. 37 - * 38 - * Note: Network errors (ECONNREFUSED, timeout, fetch failed) are handled 39 - * separately by isNetworkError and take priority in catch chains. 40 - */ 41 - export function isDatabaseError(error: unknown): boolean { 42 - if (!(error instanceof Error)) return false; 43 - const msg = error.message.toLowerCase(); 44 - return ( 45 - msg.includes("pool") || 46 - msg.includes("postgres") || 47 - msg.includes("database") || 48 - msg.includes("sql") || 49 - msg.includes("query") 50 - ); 51 - } 1 + // Re-export error classification helpers from shared package. 2 + // The canonical implementations live in @atbb/atproto. 3 + export { 4 + isProgrammingError, 5 + isNetworkError, 6 + isAuthError, 7 + isDatabaseError, 8 + } from "@atbb/atproto";
+1 -38
apps/appview/src/lib/forum-agent.ts packages/atproto/src/forum-agent.ts
··· 1 1 import { AtpAgent } from "@atproto/api"; 2 - 3 - /** 4 - * Check if an error is an authentication error (wrong credentials). 5 - * These should NOT be retried to avoid account lockouts. 6 - */ 7 - function isAuthError(error: unknown): boolean { 8 - if (!(error instanceof Error)) return false; 9 - 10 - const message = error.message.toLowerCase(); 11 - return ( 12 - message.includes("invalid identifier") || 13 - message.includes("invalid password") || 14 - message.includes("authentication failed") || 15 - message.includes("unauthorized") 16 - ); 17 - } 18 - 19 - /** 20 - * Check if an error is a network error (transient failure). 21 - * These are safe to retry with exponential backoff. 22 - */ 23 - function isNetworkError(error: unknown): boolean { 24 - if (!(error instanceof Error)) return false; 25 - 26 - const code = (error as any).code; 27 - const message = error.message.toLowerCase(); 28 - 29 - return ( 30 - code === "ECONNREFUSED" || 31 - code === "ETIMEDOUT" || 32 - code === "ENOTFOUND" || 33 - code === "ENETUNREACH" || 34 - code === "ECONNRESET" || 35 - message.includes("network") || 36 - message.includes("fetch failed") || 37 - message.includes("service unavailable") 38 - ); 39 - } 2 + import { isAuthError, isNetworkError } from "./errors.js"; 40 3 41 4 export type ForumAgentStatus = 42 5 | "initializing"
+57 -3
apps/appview/src/lib/membership.ts
··· 30 30 .limit(1); 31 31 32 32 if (existing.length > 0) { 33 + const [membership] = existing; 34 + 35 + // Bootstrap memberships (created by `atbb init`) have no backing PDS 36 + // record. Upgrade them by writing a real record to the user's PDS and 37 + // updating the DB row with the actual rkey/cid. 38 + if (membership.cid === "bootstrap") { 39 + return upgradeBootstrapMembership(ctx, agent, did, forumUri, forum.cid, membership.id); 40 + } 41 + 33 42 return { created: false }; 34 43 } 35 44 36 - // Create membership record on user's PDS 45 + return writeMembershipRecord(agent, did, forumUri, forum.cid); 46 + } 47 + 48 + async function writeMembershipRecord( 49 + agent: Agent, 50 + did: string, 51 + forumUri: string, 52 + forumCid: string 53 + ): Promise<{ created: boolean; uri?: string; cid?: string }> { 37 54 const rkey = TID.nextStr(); 38 55 const now = new Date().toISOString(); 39 56 ··· 44 61 record: { 45 62 $type: "space.atbb.membership", 46 63 forum: { 47 - forum: { uri: forumUri, cid: forum.cid }, 64 + forum: { uri: forumUri, cid: forumCid }, 48 65 }, 49 66 createdAt: now, 50 67 joinedAt: now, 51 - // role field omitted - defaults to guest/member permissions 52 68 }, 53 69 }); 54 70 55 71 return { created: true, uri: result.data.uri, cid: result.data.cid }; 56 72 } 73 + 74 + async function upgradeBootstrapMembership( 75 + ctx: AppContext, 76 + agent: Agent, 77 + did: string, 78 + forumUri: string, 79 + forumCid: string, 80 + membershipId: bigint 81 + ): Promise<{ created: boolean; uri?: string; cid?: string }> { 82 + const rkey = TID.nextStr(); 83 + const now = new Date().toISOString(); 84 + 85 + const result = await agent.com.atproto.repo.putRecord({ 86 + repo: did, 87 + collection: "space.atbb.membership", 88 + rkey, 89 + record: { 90 + $type: "space.atbb.membership", 91 + forum: { 92 + forum: { uri: forumUri, cid: forumCid }, 93 + }, 94 + createdAt: now, 95 + joinedAt: now, 96 + }, 97 + }); 98 + 99 + // Update the bootstrap row with PDS-backed values, preserving roleUri 100 + await ctx.db 101 + .update(memberships) 102 + .set({ 103 + rkey, 104 + cid: result.data.cid, 105 + indexedAt: new Date(), 106 + }) 107 + .where(eq(memberships.id, membershipId)); 108 + 109 + return { created: true, uri: result.data.uri, cid: result.data.cid }; 110 + }
+28
packages/atproto/package.json
··· 1 + { 2 + "name": "@atbb/atproto", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "main": "./dist/index.js", 7 + "types": "./dist/index.d.ts", 8 + "exports": { 9 + ".": { 10 + "types": "./dist/index.d.ts", 11 + "default": "./dist/index.js" 12 + } 13 + }, 14 + "scripts": { 15 + "build": "tsc", 16 + "lint": "tsc --noEmit", 17 + "lint:fix": "oxlint --fix src/", 18 + "clean": "rm -rf dist", 19 + "test": "vitest run --passWithNoTests" 20 + }, 21 + "dependencies": { 22 + "@atproto/api": "^0.15.0" 23 + }, 24 + "devDependencies": { 25 + "@types/node": "^22.0.0", 26 + "typescript": "^5.7.0" 27 + } 28 + }
+87
packages/atproto/src/__tests__/errors.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { isProgrammingError, isNetworkError, isAuthError, isDatabaseError } from "../errors.js"; 3 + 4 + describe("isProgrammingError", () => { 5 + it("returns true for TypeError", () => { 6 + expect(isProgrammingError(new TypeError("x is not a function"))).toBe(true); 7 + }); 8 + 9 + it("returns true for ReferenceError", () => { 10 + expect(isProgrammingError(new ReferenceError("x is not defined"))).toBe(true); 11 + }); 12 + 13 + it("returns true for SyntaxError", () => { 14 + expect(isProgrammingError(new SyntaxError("unexpected token"))).toBe(true); 15 + }); 16 + 17 + it("returns false for generic Error", () => { 18 + expect(isProgrammingError(new Error("something failed"))).toBe(false); 19 + }); 20 + 21 + it("returns false for non-error values", () => { 22 + expect(isProgrammingError("string")).toBe(false); 23 + expect(isProgrammingError(null)).toBe(false); 24 + }); 25 + }); 26 + 27 + describe("isNetworkError", () => { 28 + it("returns true for fetch failed", () => { 29 + expect(isNetworkError(new Error("fetch failed"))).toBe(true); 30 + }); 31 + 32 + it("returns true for ECONNREFUSED", () => { 33 + expect(isNetworkError(new Error("ECONNREFUSED"))).toBe(true); 34 + }); 35 + 36 + it("returns true for timeout", () => { 37 + expect(isNetworkError(new Error("request timeout"))).toBe(true); 38 + }); 39 + 40 + it("returns true for ETIMEDOUT", () => { 41 + expect(isNetworkError(new Error("ETIMEDOUT"))).toBe(true); 42 + }); 43 + 44 + it("returns false for generic Error", () => { 45 + expect(isNetworkError(new Error("something else"))).toBe(false); 46 + }); 47 + 48 + it("returns false for non-Error values", () => { 49 + expect(isNetworkError("string")).toBe(false); 50 + }); 51 + }); 52 + 53 + describe("isAuthError", () => { 54 + it("returns true for invalid credentials", () => { 55 + expect(isAuthError(new Error("Invalid identifier or password"))).toBe(true); 56 + }); 57 + 58 + it("returns true for authentication failed", () => { 59 + expect(isAuthError(new Error("Authentication failed"))).toBe(true); 60 + }); 61 + 62 + it("returns true for unauthorized", () => { 63 + expect(isAuthError(new Error("Unauthorized"))).toBe(true); 64 + }); 65 + 66 + it("returns false for network errors", () => { 67 + expect(isAuthError(new Error("fetch failed"))).toBe(false); 68 + }); 69 + 70 + it("returns false for non-Error values", () => { 71 + expect(isAuthError("string")).toBe(false); 72 + }); 73 + }); 74 + 75 + describe("isDatabaseError", () => { 76 + it("returns true for pool errors", () => { 77 + expect(isDatabaseError(new Error("pool exhausted"))).toBe(true); 78 + }); 79 + 80 + it("returns true for postgres errors", () => { 81 + expect(isDatabaseError(new Error("postgres connection lost"))).toBe(true); 82 + }); 83 + 84 + it("returns false for generic errors", () => { 85 + expect(isDatabaseError(new Error("something else"))).toBe(false); 86 + }); 87 + });
+47
packages/atproto/src/__tests__/resolve-identity.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { resolveIdentity } from "../resolve-identity.js"; 3 + import { AtpAgent } from "@atproto/api"; 4 + 5 + vi.mock("@atproto/api", () => ({ 6 + AtpAgent: vi.fn(), 7 + })); 8 + 9 + describe("resolveIdentity", () => { 10 + it("returns DID directly when input starts with 'did:'", async () => { 11 + const result = await resolveIdentity("did:plc:abc123", "https://bsky.social"); 12 + 13 + expect(result).toEqual({ did: "did:plc:abc123" }); 14 + // AtpAgent should NOT be instantiated for DID input 15 + expect(AtpAgent).not.toHaveBeenCalled(); 16 + }); 17 + 18 + it("resolves a handle to a DID via PDS", async () => { 19 + const mockResolveHandle = vi.fn().mockResolvedValue({ 20 + data: { did: "did:plc:resolved123" }, 21 + }); 22 + (AtpAgent as any).mockImplementation(function () { 23 + return { resolveHandle: mockResolveHandle }; 24 + }); 25 + 26 + const result = await resolveIdentity("alice.bsky.social", "https://bsky.social"); 27 + 28 + expect(result).toEqual({ 29 + did: "did:plc:resolved123", 30 + handle: "alice.bsky.social", 31 + }); 32 + expect(AtpAgent).toHaveBeenCalledWith({ service: "https://bsky.social" }); 33 + expect(mockResolveHandle).toHaveBeenCalledWith({ handle: "alice.bsky.social" }); 34 + }); 35 + 36 + it("throws when handle resolution fails", async () => { 37 + (AtpAgent as any).mockImplementation(function () { 38 + return { 39 + resolveHandle: vi.fn().mockRejectedValue(new Error("Unable to resolve handle")), 40 + }; 41 + }); 42 + 43 + await expect( 44 + resolveIdentity("nonexistent.bsky.social", "https://bsky.social") 45 + ).rejects.toThrow("Unable to resolve handle"); 46 + }); 47 + });
+62
packages/atproto/src/errors.ts
··· 1 + /** 2 + * Check if an error is a programming error (code bug). 3 + * Programming errors should be re-thrown, not caught. 4 + */ 5 + export function isProgrammingError(error: unknown): boolean { 6 + return ( 7 + error instanceof TypeError || 8 + error instanceof ReferenceError || 9 + error instanceof SyntaxError 10 + ); 11 + } 12 + 13 + /** 14 + * Check if an error is a network error (temporary). 15 + * Network errors should return 503 (retry later). 16 + */ 17 + export function isNetworkError(error: unknown): boolean { 18 + if (!(error instanceof Error)) return false; 19 + const msg = error.message.toLowerCase(); 20 + return ( 21 + msg.includes("fetch failed") || 22 + msg.includes("network") || 23 + msg.includes("econnrefused") || 24 + msg.includes("enotfound") || 25 + msg.includes("timeout") || 26 + msg.includes("etimedout") || 27 + msg.includes("econnreset") || 28 + msg.includes("enetunreach") || 29 + msg.includes("service unavailable") 30 + ); 31 + } 32 + 33 + /** 34 + * Check if an error is an authentication error (wrong credentials). 35 + * Auth errors should NOT be retried to avoid account lockouts. 36 + */ 37 + export function isAuthError(error: unknown): boolean { 38 + if (!(error instanceof Error)) return false; 39 + const message = error.message.toLowerCase(); 40 + return ( 41 + message.includes("invalid identifier") || 42 + message.includes("invalid password") || 43 + message.includes("authentication failed") || 44 + message.includes("unauthorized") 45 + ); 46 + } 47 + 48 + /** 49 + * Check if an error represents a database-layer failure. 50 + * These errors indicate temporary unavailability — user should retry. 51 + */ 52 + export function isDatabaseError(error: unknown): boolean { 53 + if (!(error instanceof Error)) return false; 54 + const msg = error.message.toLowerCase(); 55 + return ( 56 + msg.includes("pool") || 57 + msg.includes("postgres") || 58 + msg.includes("database") || 59 + msg.includes("sql") || 60 + msg.includes("query") 61 + ); 62 + }
+13
packages/atproto/src/index.ts
··· 1 + // @atbb/atproto — Shared AT Protocol utilities 2 + export { 3 + isProgrammingError, 4 + isNetworkError, 5 + isAuthError, 6 + isDatabaseError, 7 + } from "./errors.js"; 8 + 9 + export { ForumAgent } from "./forum-agent.js"; 10 + export type { ForumAgentStatus, ForumAgentState } from "./forum-agent.js"; 11 + 12 + export { resolveIdentity } from "./resolve-identity.js"; 13 + export type { ResolvedIdentity } from "./resolve-identity.js";
+24
packages/atproto/src/resolve-identity.ts
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + 3 + export interface ResolvedIdentity { 4 + did: string; 5 + handle?: string; 6 + } 7 + 8 + /** 9 + * Resolve a handle or DID string to a confirmed DID. 10 + * If the input already starts with "did:", returns it directly. 11 + * Otherwise, treats it as a handle and resolves via the PDS. 12 + */ 13 + export async function resolveIdentity( 14 + input: string, 15 + pdsUrl: string 16 + ): Promise<ResolvedIdentity> { 17 + if (input.startsWith("did:")) { 18 + return { did: input }; 19 + } 20 + 21 + const agent = new AtpAgent({ service: pdsUrl }); 22 + const res = await agent.resolveHandle({ handle: input }); 23 + return { did: res.data.did, handle: input }; 24 + }
+8
packages/atproto/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.base.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*.ts"] 8 + }
+33
packages/cli/package.json
··· 1 + { 2 + "name": "@atbb/cli", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "bin": { 7 + "atbb": "./dist/index.js" 8 + }, 9 + "scripts": { 10 + "build": "tsc", 11 + "dev": "tsx --env-file=../../.env src/index.ts", 12 + "lint": "tsc --noEmit", 13 + "lint:fix": "oxlint --fix src/", 14 + "clean": "rm -rf dist", 15 + "test": "vitest run --passWithNoTests" 16 + }, 17 + "dependencies": { 18 + "@atbb/atproto": "workspace:*", 19 + "@atbb/db": "workspace:*", 20 + "@atproto/api": "^0.15.0", 21 + "citty": "^0.1.6", 22 + "consola": "^3.4.0", 23 + "@inquirer/prompts": "^7.0.0", 24 + "drizzle-orm": "^0.45.1", 25 + "postgres": "^3.4.8" 26 + }, 27 + "devDependencies": { 28 + "@types/node": "^22.0.0", 29 + "tsx": "^4.0.0", 30 + "typescript": "^5.7.0", 31 + "vitest": "^3.0.0" 32 + } 33 + }
+74
packages/cli/src/__tests__/assign-owner.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { assignOwnerRole } from "../lib/steps/assign-owner.js"; 3 + import type { SeededRole } from "../lib/steps/seed-roles.js"; 4 + 5 + describe("assignOwnerRole", () => { 6 + const forumDid = "did:plc:testforum"; 7 + const ownerDid = "did:plc:owner123"; 8 + const ownerHandle = "alice.test"; 9 + 10 + const seededRoles: SeededRole[] = [ 11 + { name: "Owner", uri: `at://${forumDid}/space.atbb.forum.role/ownerrkey`, cid: "bafyrole" }, 12 + { name: "Admin", uri: `at://${forumDid}/space.atbb.forum.role/adminrkey`, cid: "bafyadmin" }, 13 + ]; 14 + 15 + function mockDb(options: { existingMembership?: any } = {}) { 16 + const selectMock = vi.fn(); 17 + 18 + selectMock.mockImplementation(() => ({ 19 + from: vi.fn().mockReturnValue({ 20 + where: vi.fn().mockReturnValue({ 21 + limit: vi.fn().mockImplementation(() => { 22 + return options.existingMembership ? [options.existingMembership] : []; 23 + }), 24 + }), 25 + }), 26 + })); 27 + 28 + return { 29 + select: selectMock, 30 + insert: vi.fn().mockReturnValue({ 31 + values: vi.fn().mockReturnValue({ 32 + onConflictDoNothing: vi.fn().mockResolvedValue(undefined), 33 + }), 34 + }), 35 + } as any; 36 + } 37 + 38 + it("assigns owner role via direct DB insert", async () => { 39 + const db = mockDb(); 40 + 41 + const result = await assignOwnerRole(db, forumDid, ownerDid, ownerHandle, seededRoles); 42 + 43 + expect(result.assigned).toBe(true); 44 + expect(result.skipped).toBe(false); 45 + expect(result.roleUri).toBe(seededRoles[0].uri); 46 + // Verify user was inserted (upsert) 47 + expect(db.insert).toHaveBeenCalled(); 48 + }); 49 + 50 + it("skips when user already has Owner role", async () => { 51 + const db = mockDb({ 52 + existingMembership: { 53 + did: ownerDid, 54 + roleUri: seededRoles[0].uri, 55 + }, 56 + }); 57 + 58 + const result = await assignOwnerRole(db, forumDid, ownerDid, ownerHandle, seededRoles); 59 + 60 + expect(result.assigned).toBe(false); 61 + expect(result.skipped).toBe(true); 62 + }); 63 + 64 + it("throws when Owner role is not in seeded roles", async () => { 65 + const db = mockDb(); 66 + const rolesWithoutOwner: SeededRole[] = [ 67 + { name: "Admin", uri: "at://did/role/admin", cid: "bafyadmin" }, 68 + ]; 69 + 70 + await expect( 71 + assignOwnerRole(db, forumDid, ownerDid, ownerHandle, rolesWithoutOwner) 72 + ).rejects.toThrow("Owner role not found"); 73 + }); 74 + });
+53
packages/cli/src/__tests__/config.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import { loadCliConfig } from "../lib/config.js"; 3 + 4 + describe("loadCliConfig", () => { 5 + beforeEach(() => { 6 + vi.unstubAllEnvs(); 7 + }); 8 + 9 + it("loads all required env vars", () => { 10 + vi.stubEnv("DATABASE_URL", "postgres://localhost:5432/atbb"); 11 + vi.stubEnv("FORUM_DID", "did:plc:test123"); 12 + vi.stubEnv("PDS_URL", "https://bsky.social"); 13 + vi.stubEnv("FORUM_HANDLE", "forum.example.com"); 14 + vi.stubEnv("FORUM_PASSWORD", "secret"); 15 + 16 + const config = loadCliConfig(); 17 + 18 + expect(config.databaseUrl).toBe("postgres://localhost:5432/atbb"); 19 + expect(config.forumDid).toBe("did:plc:test123"); 20 + expect(config.pdsUrl).toBe("https://bsky.social"); 21 + expect(config.forumHandle).toBe("forum.example.com"); 22 + expect(config.forumPassword).toBe("secret"); 23 + }); 24 + 25 + it("returns missing fields list when env vars are absent", () => { 26 + // Explicitly stub all checked vars to empty — vi.unstubAllEnvs() only 27 + // reverses previous stubs, it does NOT clear real env vars (e.g. CI 28 + // sets DATABASE_URL for its PostgreSQL service container). 29 + vi.stubEnv("DATABASE_URL", ""); 30 + vi.stubEnv("FORUM_DID", ""); 31 + vi.stubEnv("FORUM_HANDLE", ""); 32 + vi.stubEnv("FORUM_PASSWORD", ""); 33 + 34 + const config = loadCliConfig(); 35 + 36 + expect(config.missing).toContain("DATABASE_URL"); 37 + expect(config.missing).toContain("FORUM_DID"); 38 + expect(config.missing).toContain("FORUM_HANDLE"); 39 + expect(config.missing).toContain("FORUM_PASSWORD"); 40 + }); 41 + 42 + it("defaults PDS_URL to https://bsky.social", () => { 43 + vi.stubEnv("DATABASE_URL", "postgres://localhost/atbb"); 44 + vi.stubEnv("FORUM_DID", "did:plc:test"); 45 + vi.stubEnv("FORUM_HANDLE", "handle"); 46 + vi.stubEnv("FORUM_PASSWORD", "pass"); 47 + 48 + const config = loadCliConfig(); 49 + 50 + expect(config.pdsUrl).toBe("https://bsky.social"); 51 + expect(config.missing).toHaveLength(0); 52 + }); 53 + });
+116
packages/cli/src/__tests__/create-forum.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { createForumRecord } from "../lib/steps/create-forum.js"; 3 + 4 + describe("createForumRecord", () => { 5 + const forumDid = "did:plc:testforum"; 6 + 7 + function mockDb() { 8 + return { 9 + insert: vi.fn().mockReturnValue({ 10 + values: vi.fn().mockResolvedValue(undefined), 11 + }), 12 + } as any; 13 + } 14 + 15 + // XRPC "RecordNotFound" error mimics @atproto/api behavior 16 + function recordNotFoundError() { 17 + const err = Object.assign(new Error("Record not found"), { 18 + status: 400, 19 + error: "RecordNotFound", 20 + }); 21 + return err; 22 + } 23 + 24 + function mockAgent(overrides: Record<string, any> = {}) { 25 + return { 26 + com: { 27 + atproto: { 28 + repo: { 29 + getRecord: vi.fn().mockRejectedValue(recordNotFoundError()), 30 + createRecord: vi.fn().mockResolvedValue({ 31 + data: { uri: `at://${forumDid}/space.atbb.forum.forum/self`, cid: "bafytest" }, 32 + }), 33 + ...overrides, 34 + }, 35 + }, 36 + }, 37 + } as any; 38 + } 39 + 40 + it("creates forum record and inserts into DB when it does not exist", async () => { 41 + const db = mockDb(); 42 + const agent = mockAgent(); 43 + 44 + const result = await createForumRecord(db, agent, forumDid, { 45 + name: "My Forum", 46 + description: "A test forum", 47 + }); 48 + 49 + expect(result.created).toBe(true); 50 + expect(result.cid).toBe("bafytest"); 51 + expect(result.uri).toContain("space.atbb.forum.forum/self"); 52 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledWith( 53 + expect.objectContaining({ 54 + repo: forumDid, 55 + collection: "space.atbb.forum.forum", 56 + rkey: "self", 57 + record: expect.objectContaining({ 58 + $type: "space.atbb.forum.forum", 59 + name: "My Forum", 60 + description: "A test forum", 61 + }), 62 + }) 63 + ); 64 + // Verify DB insertion 65 + expect(db.insert).toHaveBeenCalled(); 66 + }); 67 + 68 + it("skips creation when forum record already exists", async () => { 69 + const db = mockDb(); 70 + const agent = mockAgent({ 71 + getRecord: vi.fn().mockResolvedValue({ 72 + data: { 73 + uri: `at://${forumDid}/space.atbb.forum.forum/self`, 74 + cid: "bafyexisting", 75 + value: { name: "Existing Forum" }, 76 + }, 77 + }), 78 + }); 79 + 80 + const result = await createForumRecord(db, agent, forumDid, { 81 + name: "My Forum", 82 + }); 83 + 84 + expect(result.created).toBe(false); 85 + expect(result.skipped).toBe(true); 86 + expect(result.cid).toBe("bafyexisting"); 87 + expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); 88 + // No DB insertion when skipped 89 + expect(db.insert).not.toHaveBeenCalled(); 90 + }); 91 + 92 + it("throws when PDS write fails", async () => { 93 + const db = mockDb(); 94 + const agent = mockAgent({ 95 + createRecord: vi.fn().mockRejectedValue(new Error("PDS write failed")), 96 + }); 97 + 98 + await expect( 99 + createForumRecord(db, agent, forumDid, { name: "My Forum" }) 100 + ).rejects.toThrow("PDS write failed"); 101 + }); 102 + 103 + it("re-throws non-RecordNotFound errors from getRecord", async () => { 104 + const db = mockDb(); 105 + const agent = mockAgent({ 106 + getRecord: vi.fn().mockRejectedValue(new Error("Network timeout")), 107 + }); 108 + 109 + await expect( 110 + createForumRecord(db, agent, forumDid, { name: "My Forum" }) 111 + ).rejects.toThrow("Network timeout"); 112 + 113 + // Should never attempt createRecord 114 + expect(agent.com.atproto.repo.createRecord).not.toHaveBeenCalled(); 115 + }); 116 + });
+40
packages/cli/src/__tests__/preflight.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { checkEnvironment } from "../lib/preflight.js"; 3 + import type { CliConfig } from "../lib/config.js"; 4 + 5 + describe("checkEnvironment", () => { 6 + it("returns success when all required vars are present", () => { 7 + const config: CliConfig = { 8 + databaseUrl: "postgres://localhost/atbb", 9 + forumDid: "did:plc:test", 10 + pdsUrl: "https://bsky.social", 11 + forumHandle: "forum.example.com", 12 + forumPassword: "secret", 13 + missing: [], 14 + }; 15 + 16 + const result = checkEnvironment(config); 17 + 18 + expect(result.ok).toBe(true); 19 + expect(result.errors).toHaveLength(0); 20 + }); 21 + 22 + it("returns errors when required vars are missing", () => { 23 + const config: CliConfig = { 24 + databaseUrl: "", 25 + forumDid: "", 26 + pdsUrl: "https://bsky.social", 27 + forumHandle: "", 28 + forumPassword: "", 29 + missing: ["DATABASE_URL", "FORUM_DID", "FORUM_HANDLE", "FORUM_PASSWORD"], 30 + }; 31 + 32 + const result = checkEnvironment(config); 33 + 34 + expect(result.ok).toBe(false); 35 + expect(result.errors).toContain("DATABASE_URL"); 36 + expect(result.errors).toContain("FORUM_DID"); 37 + expect(result.errors).toContain("FORUM_HANDLE"); 38 + expect(result.errors).toContain("FORUM_PASSWORD"); 39 + }); 40 + });
+100
packages/cli/src/__tests__/seed-roles.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { seedDefaultRoles, DEFAULT_ROLES } from "../lib/steps/seed-roles.js"; 3 + 4 + describe("seedDefaultRoles", () => { 5 + const forumDid = "did:plc:testforum"; 6 + 7 + function mockDb(existingRoleNames: string[] = []) { 8 + const existingQueue = [...existingRoleNames]; 9 + 10 + return { 11 + select: vi.fn().mockReturnValue({ 12 + from: vi.fn().mockReturnValue({ 13 + where: vi.fn().mockReturnValue({ 14 + limit: vi.fn().mockImplementation(() => { 15 + const roleName = existingQueue.shift(); 16 + if (roleName) { 17 + return [{ 18 + name: roleName, 19 + did: forumDid, 20 + rkey: roleName.toLowerCase(), 21 + cid: `bafyexisting-${roleName.toLowerCase()}`, 22 + }]; 23 + } 24 + return []; 25 + }), 26 + }), 27 + }), 28 + }), 29 + insert: vi.fn().mockReturnValue({ 30 + values: vi.fn().mockResolvedValue(undefined), 31 + }), 32 + } as any; 33 + } 34 + 35 + function mockAgent() { 36 + let callCount = 0; 37 + return { 38 + com: { 39 + atproto: { 40 + repo: { 41 + createRecord: vi.fn().mockImplementation(() => { 42 + callCount++; 43 + return Promise.resolve({ 44 + data: { 45 + uri: `at://${forumDid}/space.atbb.forum.role/tid${callCount}`, 46 + cid: `bafynew${callCount}`, 47 + }, 48 + }); 49 + }), 50 + }, 51 + }, 52 + }, 53 + } as any; 54 + } 55 + 56 + it("exports DEFAULT_ROLES with correct structure", () => { 57 + expect(DEFAULT_ROLES).toHaveLength(4); 58 + expect(DEFAULT_ROLES[0].name).toBe("Owner"); 59 + expect(DEFAULT_ROLES[0].priority).toBe(0); 60 + expect(DEFAULT_ROLES[3].name).toBe("Member"); 61 + expect(DEFAULT_ROLES[3].priority).toBe(30); 62 + }); 63 + 64 + it("creates all roles on PDS and DB when none exist", async () => { 65 + const db = mockDb(); 66 + const agent = mockAgent(); 67 + 68 + const result = await seedDefaultRoles(db, agent, forumDid); 69 + 70 + expect(result.created).toBe(4); 71 + expect(result.skipped).toBe(0); 72 + expect(agent.com.atproto.repo.createRecord).toHaveBeenCalledTimes(4); 73 + // Verify DB insertions 74 + expect(db.insert).toHaveBeenCalledTimes(4); 75 + // Verify returned role data 76 + expect(result.roles).toHaveLength(4); 77 + expect(result.roles[0].name).toBe("Owner"); 78 + expect(result.roles[0].uri).toContain("space.atbb.forum.role/"); 79 + expect(result.roles[0].cid).toBeTruthy(); 80 + }); 81 + 82 + it("skips existing roles and returns their data", async () => { 83 + // Simulate Owner and Admin already existing 84 + const db = mockDb(["Owner", "Admin"]); 85 + const agent = mockAgent(); 86 + 87 + const result = await seedDefaultRoles(db, agent, forumDid); 88 + 89 + expect(result.skipped).toBe(2); 90 + expect(result.created).toBe(2); 91 + // All 4 roles should be in the return value 92 + expect(result.roles).toHaveLength(4); 93 + // Existing roles use data from DB 94 + expect(result.roles[0].name).toBe("Owner"); 95 + expect(result.roles[0].cid).toBe("bafyexisting-owner"); 96 + // New roles use data from PDS response 97 + expect(result.roles[2].name).toBe("Moderator"); 98 + expect(result.roles[2].cid).toMatch(/^bafynew/); 99 + }); 100 + });
+194
packages/cli/src/commands/init.ts
··· 1 + import { defineCommand } from "citty"; 2 + import consola from "consola"; 3 + import { input } from "@inquirer/prompts"; 4 + import postgres from "postgres"; 5 + import { drizzle } from "drizzle-orm/postgres-js"; 6 + import * as schema from "@atbb/db"; 7 + import { ForumAgent, resolveIdentity } from "@atbb/atproto"; 8 + import { loadCliConfig } from "../lib/config.js"; 9 + import { checkEnvironment } from "../lib/preflight.js"; 10 + import { createForumRecord } from "../lib/steps/create-forum.js"; 11 + import { seedDefaultRoles } from "../lib/steps/seed-roles.js"; 12 + import { assignOwnerRole } from "../lib/steps/assign-owner.js"; 13 + 14 + export const initCommand = defineCommand({ 15 + meta: { 16 + name: "init", 17 + description: "Bootstrap a new atBB forum instance", 18 + }, 19 + args: { 20 + "forum-name": { 21 + type: "string", 22 + description: "Forum name", 23 + }, 24 + "forum-description": { 25 + type: "string", 26 + description: "Forum description", 27 + }, 28 + owner: { 29 + type: "string", 30 + description: "Owner handle or DID (e.g., alice.bsky.social or did:plc:abc123)", 31 + }, 32 + }, 33 + async run({ args }) { 34 + consola.box("atBB Forum Setup"); 35 + 36 + // Step 0: Preflight checks 37 + consola.start("Checking environment..."); 38 + const config = loadCliConfig(); 39 + const envCheck = checkEnvironment(config); 40 + 41 + if (!envCheck.ok) { 42 + consola.error("Missing required environment variables:"); 43 + for (const name of envCheck.errors) { 44 + consola.error(` - ${name}`); 45 + } 46 + consola.info("Set these in your .env file or environment, then re-run."); 47 + process.exit(1); 48 + } 49 + 50 + consola.success(`DATABASE_URL configured`); 51 + consola.success(`FORUM_DID: ${config.forumDid}`); 52 + consola.success(`PDS_URL: ${config.pdsUrl}`); 53 + consola.success(`FORUM_HANDLE / FORUM_PASSWORD configured`); 54 + 55 + // Step 1: Connect to database 56 + // Create the postgres client directly so we can close it on exit. 57 + consola.start("Connecting to database..."); 58 + const sql = postgres(config.databaseUrl); 59 + const db = drizzle(sql, { schema }); 60 + 61 + async function cleanup() { 62 + await sql.end(); 63 + } 64 + 65 + try { 66 + await sql`SELECT 1`; 67 + consola.success("Database connection successful"); 68 + } catch (error) { 69 + consola.error("Failed to connect to database:", error instanceof Error ? error.message : String(error)); 70 + consola.info("Check your DATABASE_URL and ensure the database is running."); 71 + await cleanup(); 72 + process.exit(1); 73 + } 74 + 75 + // Step 2: Authenticate as Forum DID 76 + consola.start("Authenticating as Forum DID..."); 77 + const forumAgent = new ForumAgent(config.pdsUrl, config.forumHandle, config.forumPassword); 78 + await forumAgent.initialize(); 79 + 80 + if (!forumAgent.isAuthenticated()) { 81 + const status = forumAgent.getStatus(); 82 + consola.error(`Failed to authenticate: ${status.error}`); 83 + if (status.status === "failed") { 84 + consola.info("Check your FORUM_HANDLE and FORUM_PASSWORD."); 85 + } 86 + await forumAgent.shutdown(); 87 + await cleanup(); 88 + process.exit(1); 89 + } 90 + 91 + const agent = forumAgent.getAgent()!; 92 + consola.success(`Authenticated as ${config.forumHandle}`); 93 + 94 + // Step 3: Create forum record (PDS + DB) 95 + consola.log(""); 96 + consola.info("Step 1: Create Forum Record"); 97 + 98 + const forumName = args["forum-name"] ?? await input({ 99 + message: "Forum name:", 100 + default: "My Forum", 101 + }); 102 + 103 + const forumDescription = args["forum-description"] ?? await input({ 104 + message: "Forum description (optional):", 105 + }); 106 + 107 + try { 108 + const forumResult = await createForumRecord(db, agent, config.forumDid, { 109 + name: forumName, 110 + ...(forumDescription && { description: forumDescription }), 111 + }); 112 + 113 + if (forumResult.skipped) { 114 + consola.warn(`Forum record already exists: "${forumResult.existingName}"`); 115 + } else { 116 + consola.success(`Created forum record: ${forumResult.uri}`); 117 + } 118 + } catch (error) { 119 + consola.error("Failed to create forum record:", error instanceof Error ? error.message : String(error)); 120 + await forumAgent.shutdown(); 121 + await cleanup(); 122 + process.exit(1); 123 + } 124 + 125 + // Step 4: Seed default roles (PDS + DB) 126 + consola.log(""); 127 + consola.info("Step 2: Seed Default Roles"); 128 + 129 + let seededRoles; 130 + try { 131 + const rolesResult = await seedDefaultRoles(db, agent, config.forumDid); 132 + seededRoles = rolesResult.roles; 133 + if (rolesResult.created > 0) { 134 + consola.success(`Created ${rolesResult.created} role(s)`); 135 + } 136 + if (rolesResult.skipped > 0) { 137 + consola.warn(`Skipped ${rolesResult.skipped} existing role(s)`); 138 + } 139 + } catch (error) { 140 + consola.error("Failed to seed roles:", error instanceof Error ? error.message : String(error)); 141 + await forumAgent.shutdown(); 142 + await cleanup(); 143 + process.exit(1); 144 + } 145 + 146 + // Step 5: Assign owner (DB only — no PDS write since we lack user credentials) 147 + consola.log(""); 148 + consola.info("Step 3: Assign Forum Owner"); 149 + 150 + const ownerInput = args.owner ?? await input({ 151 + message: "Owner handle or DID:", 152 + }); 153 + 154 + try { 155 + consola.start("Resolving identity..."); 156 + const identity = await resolveIdentity(ownerInput, config.pdsUrl); 157 + 158 + if (identity.handle) { 159 + consola.success(`Resolved ${identity.handle} to ${identity.did}`); 160 + } 161 + 162 + const ownerResult = await assignOwnerRole( 163 + db, config.forumDid, identity.did, identity.handle, seededRoles 164 + ); 165 + 166 + if (ownerResult.skipped) { 167 + consola.warn(`${ownerInput} already has the Owner role`); 168 + } else { 169 + consola.success(`Assigned Owner role to ${ownerInput}`); 170 + } 171 + } catch (error) { 172 + consola.error("Failed to assign owner:", error instanceof Error ? error.message : String(error)); 173 + await forumAgent.shutdown(); 174 + await cleanup(); 175 + process.exit(1); 176 + } 177 + 178 + // Done — close connections 179 + await forumAgent.shutdown(); 180 + await cleanup(); 181 + 182 + consola.log(""); 183 + consola.box({ 184 + title: "Forum bootstrap complete!", 185 + message: [ 186 + "Next steps:", 187 + " 1. Start the appview: pnpm --filter @atbb/appview dev", 188 + " 2. Start the web UI: pnpm --filter @atbb/web dev", 189 + ` 3. Log in as ${ownerInput} to access admin features`, 190 + " 4. Create categories and boards from the admin panel", 191 + ].join("\n"), 192 + }); 193 + }, 194 + });
+16
packages/cli/src/index.ts
··· 1 + #!/usr/bin/env node 2 + import { defineCommand, runMain } from "citty"; 3 + import { initCommand } from "./commands/init.js"; 4 + 5 + const main = defineCommand({ 6 + meta: { 7 + name: "atbb", 8 + version: "0.1.0", 9 + description: "atBB Forum management CLI", 10 + }, 11 + subCommands: { 12 + init: initCommand, 13 + }, 14 + }); 15 + 16 + runMain(main);
+29
packages/cli/src/lib/config.ts
··· 1 + export interface CliConfig { 2 + databaseUrl: string; 3 + forumDid: string; 4 + pdsUrl: string; 5 + forumHandle: string; 6 + forumPassword: string; 7 + missing: string[]; 8 + } 9 + 10 + /** 11 + * Load CLI configuration from environment variables. 12 + * Returns a config object with a `missing` array listing absent required vars. 13 + */ 14 + export function loadCliConfig(): CliConfig { 15 + const missing: string[] = []; 16 + 17 + const databaseUrl = process.env.DATABASE_URL ?? ""; 18 + const forumDid = process.env.FORUM_DID ?? ""; 19 + const pdsUrl = process.env.PDS_URL ?? "https://bsky.social"; 20 + const forumHandle = process.env.FORUM_HANDLE ?? ""; 21 + const forumPassword = process.env.FORUM_PASSWORD ?? ""; 22 + 23 + if (!databaseUrl) missing.push("DATABASE_URL"); 24 + if (!forumDid) missing.push("FORUM_DID"); 25 + if (!forumHandle) missing.push("FORUM_HANDLE"); 26 + if (!forumPassword) missing.push("FORUM_PASSWORD"); 27 + 28 + return { databaseUrl, forumDid, pdsUrl, forumHandle, forumPassword, missing }; 29 + }
+16
packages/cli/src/lib/preflight.ts
··· 1 + import type { CliConfig } from "./config.js"; 2 + 3 + export interface PreflightResult { 4 + ok: boolean; 5 + errors: string[]; 6 + } 7 + 8 + /** 9 + * Check that all required environment variables are present. 10 + */ 11 + export function checkEnvironment(config: CliConfig): PreflightResult { 12 + if (config.missing.length === 0) { 13 + return { ok: true, errors: [] }; 14 + } 15 + return { ok: false, errors: config.missing }; 16 + }
+81
packages/cli/src/lib/steps/assign-owner.ts
··· 1 + import type { Database } from "@atbb/db"; 2 + import { users, memberships } from "@atbb/db"; 3 + import { eq, and } from "drizzle-orm"; 4 + import type { SeededRole } from "./seed-roles.js"; 5 + 6 + interface AssignOwnerResult { 7 + assigned: boolean; 8 + skipped: boolean; 9 + roleUri?: string; 10 + } 11 + 12 + /** 13 + * Assign the Owner role to a user via direct DB insert. 14 + * 15 + * The CLI cannot write to the user's PDS (no user credentials), so 16 + * this inserts the membership directly into the database as a bootstrap 17 + * shortcut. When the user logs in via OAuth, the normal membership 18 + * flow will create the PDS record on their repo. 19 + * 20 + * Idempotent: skips if the user already has a membership with the 21 + * Owner role URI. 22 + */ 23 + export async function assignOwnerRole( 24 + db: Database, 25 + forumDid: string, 26 + ownerDid: string, 27 + ownerHandle: string | undefined, 28 + seededRoles: SeededRole[] 29 + ): Promise<AssignOwnerResult> { 30 + // Find the Owner role from seeded roles 31 + const ownerRole = seededRoles.find((r) => r.name === "Owner"); 32 + 33 + if (!ownerRole) { 34 + throw new Error( 35 + "Owner role not found in seeded roles. Run role seeding first." 36 + ); 37 + } 38 + 39 + const forumUri = `at://${forumDid}/space.atbb.forum.forum/self`; 40 + 41 + // Check if user already has a membership with this role 42 + const [existingMembership] = await db 43 + .select() 44 + .from(memberships) 45 + .where(and(eq(memberships.did, ownerDid), eq(memberships.roleUri, ownerRole.uri))) 46 + .limit(1); 47 + 48 + if (existingMembership) { 49 + return { assigned: false, skipped: true, roleUri: ownerRole.uri }; 50 + } 51 + 52 + // Ensure user exists in the users table (FK constraint) 53 + await db 54 + .insert(users) 55 + .values({ 56 + did: ownerDid, 57 + handle: ownerHandle ?? null, 58 + indexedAt: new Date(), 59 + }) 60 + .onConflictDoNothing(); 61 + 62 + // Insert membership directly into DB. 63 + // rkey and cid use "bootstrap" sentinel — there is no PDS record backing 64 + // this membership yet. When the user first logs in via OAuth, 65 + // createMembershipForUser detects cid==="bootstrap" and upgrades the row 66 + // by writing a real PDS record and updating rkey/cid. 67 + const now = new Date(); 68 + await db.insert(memberships).values({ 69 + did: ownerDid, 70 + rkey: "bootstrap", 71 + cid: "bootstrap", 72 + forumUri, 73 + roleUri: ownerRole.uri, 74 + role: "Owner", 75 + joinedAt: now, 76 + createdAt: now, 77 + indexedAt: now, 78 + }); 79 + 80 + return { assigned: true, skipped: false, roleUri: ownerRole.uri }; 81 + }
+88
packages/cli/src/lib/steps/create-forum.ts
··· 1 + import type { AtpAgent } from "@atproto/api"; 2 + import type { Database } from "@atbb/db"; 3 + import { forums } from "@atbb/db"; 4 + import { isProgrammingError } from "@atbb/atproto"; 5 + 6 + interface CreateForumInput { 7 + name: string; 8 + description?: string; 9 + } 10 + 11 + interface CreateForumResult { 12 + created: boolean; 13 + skipped: boolean; 14 + uri?: string; 15 + cid?: string; 16 + existingName?: string; 17 + } 18 + 19 + /** 20 + * Create the space.atbb.forum.forum/self record on the Forum DID's PDS 21 + * and insert it into the database. 22 + * Idempotent: skips if the record already exists on the PDS. 23 + */ 24 + export async function createForumRecord( 25 + db: Database, 26 + agent: AtpAgent, 27 + forumDid: string, 28 + input: CreateForumInput 29 + ): Promise<CreateForumResult> { 30 + // Check if forum record already exists 31 + try { 32 + const existing = await agent.com.atproto.repo.getRecord({ 33 + repo: forumDid, 34 + collection: "space.atbb.forum.forum", 35 + rkey: "self", 36 + }); 37 + 38 + return { 39 + created: false, 40 + skipped: true, 41 + uri: existing.data.uri, 42 + cid: existing.data.cid, 43 + existingName: (existing.data.value as any)?.name, 44 + }; 45 + } catch (error) { 46 + if (isProgrammingError(error)) throw error; 47 + 48 + // Only proceed if the record was not found. 49 + // XRPC "RecordNotFound" errors have an `error` property on the thrown object. 50 + const isNotFound = 51 + error instanceof Error && 52 + "status" in error && 53 + (error as any).error === "RecordNotFound"; 54 + 55 + if (!isNotFound) { 56 + throw error; 57 + } 58 + } 59 + 60 + const response = await agent.com.atproto.repo.createRecord({ 61 + repo: forumDid, 62 + collection: "space.atbb.forum.forum", 63 + rkey: "self", 64 + record: { 65 + $type: "space.atbb.forum.forum", 66 + name: input.name, 67 + ...(input.description && { description: input.description }), 68 + createdAt: new Date().toISOString(), 69 + }, 70 + }); 71 + 72 + // Insert forum record into DB so downstream steps can reference it 73 + await db.insert(forums).values({ 74 + did: forumDid, 75 + rkey: "self", 76 + cid: response.data.cid, 77 + name: input.name, 78 + description: input.description ?? null, 79 + indexedAt: new Date(), 80 + }); 81 + 82 + return { 83 + created: true, 84 + skipped: false, 85 + uri: response.data.uri, 86 + cid: response.data.cid, 87 + }; 88 + }
+144
packages/cli/src/lib/steps/seed-roles.ts
··· 1 + import type { AtpAgent } from "@atproto/api"; 2 + import type { Database } from "@atbb/db"; 3 + import { roles } from "@atbb/db"; 4 + import { eq } from "drizzle-orm"; 5 + 6 + interface DefaultRole { 7 + name: string; 8 + description: string; 9 + permissions: string[]; 10 + priority: number; 11 + } 12 + 13 + export const DEFAULT_ROLES: DefaultRole[] = [ 14 + { 15 + name: "Owner", 16 + description: "Forum owner with full control", 17 + permissions: ["*"], 18 + priority: 0, 19 + }, 20 + { 21 + name: "Admin", 22 + description: "Can manage forum structure and users", 23 + permissions: [ 24 + "space.atbb.permission.manageCategories", 25 + "space.atbb.permission.manageRoles", 26 + "space.atbb.permission.manageMembers", 27 + "space.atbb.permission.moderatePosts", 28 + "space.atbb.permission.banUsers", 29 + "space.atbb.permission.pinTopics", 30 + "space.atbb.permission.lockTopics", 31 + "space.atbb.permission.createTopics", 32 + "space.atbb.permission.createPosts", 33 + ], 34 + priority: 10, 35 + }, 36 + { 37 + name: "Moderator", 38 + description: "Can moderate content and users", 39 + permissions: [ 40 + "space.atbb.permission.moderatePosts", 41 + "space.atbb.permission.banUsers", 42 + "space.atbb.permission.pinTopics", 43 + "space.atbb.permission.lockTopics", 44 + "space.atbb.permission.createTopics", 45 + "space.atbb.permission.createPosts", 46 + ], 47 + priority: 20, 48 + }, 49 + { 50 + name: "Member", 51 + description: "Regular forum member", 52 + permissions: [ 53 + "space.atbb.permission.createTopics", 54 + "space.atbb.permission.createPosts", 55 + ], 56 + priority: 30, 57 + }, 58 + ]; 59 + 60 + export interface SeededRole { 61 + name: string; 62 + uri: string; 63 + cid: string; 64 + } 65 + 66 + interface SeedRolesResult { 67 + created: number; 68 + skipped: number; 69 + roles: SeededRole[]; 70 + } 71 + 72 + /** 73 + * Seed default roles to Forum DID's PDS and database. 74 + * Idempotent: checks for existing roles by name before creating. 75 + * Returns role data (URI + CID) for downstream steps. 76 + */ 77 + export async function seedDefaultRoles( 78 + db: Database, 79 + agent: AtpAgent, 80 + forumDid: string 81 + ): Promise<SeedRolesResult> { 82 + let created = 0; 83 + let skipped = 0; 84 + const seededRoles: SeededRole[] = []; 85 + 86 + for (const defaultRole of DEFAULT_ROLES) { 87 + // Check if role already exists by name 88 + const [existingRole] = await db 89 + .select() 90 + .from(roles) 91 + .where(eq(roles.name, defaultRole.name)) 92 + .limit(1); 93 + 94 + if (existingRole) { 95 + skipped++; 96 + seededRoles.push({ 97 + name: existingRole.name, 98 + uri: `at://${existingRole.did}/space.atbb.forum.role/${existingRole.rkey}`, 99 + cid: existingRole.cid, 100 + }); 101 + continue; 102 + } 103 + 104 + // Create role record on Forum DID's PDS 105 + const response = await agent.com.atproto.repo.createRecord({ 106 + repo: forumDid, 107 + collection: "space.atbb.forum.role", 108 + record: { 109 + $type: "space.atbb.forum.role", 110 + name: defaultRole.name, 111 + description: defaultRole.description, 112 + permissions: defaultRole.permissions, 113 + priority: defaultRole.priority, 114 + createdAt: new Date().toISOString(), 115 + }, 116 + }); 117 + 118 + // Extract rkey from the returned URI (at://did/collection/rkey) 119 + const rkey = response.data.uri.split("/").pop()!; 120 + 121 + // Insert into database so downstream steps can query it 122 + await db.insert(roles).values({ 123 + did: forumDid, 124 + rkey, 125 + cid: response.data.cid, 126 + name: defaultRole.name, 127 + description: defaultRole.description, 128 + permissions: defaultRole.permissions, 129 + priority: defaultRole.priority, 130 + createdAt: new Date(), 131 + indexedAt: new Date(), 132 + }); 133 + 134 + seededRoles.push({ 135 + name: defaultRole.name, 136 + uri: response.data.uri, 137 + cid: response.data.cid, 138 + }); 139 + 140 + created++; 141 + } 142 + 143 + return { created, skipped, roles: seededRoles }; 144 + }
+8
packages/cli/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.base.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*.ts"] 8 + }
+409
pnpm-lock.yaml
··· 26 26 27 27 apps/appview: 28 28 dependencies: 29 + '@atbb/atproto': 30 + specifier: workspace:* 31 + version: link:../../packages/atproto 29 32 '@atbb/db': 30 33 specifier: workspace:* 31 34 version: link:../../packages/db ··· 103 106 typescript: 104 107 specifier: ^5.7.0 105 108 version: 5.9.3 109 + 110 + packages/atproto: 111 + dependencies: 112 + '@atproto/api': 113 + specifier: ^0.15.0 114 + version: 0.15.27 115 + devDependencies: 116 + '@types/node': 117 + specifier: ^22.0.0 118 + version: 22.19.9 119 + typescript: 120 + specifier: ^5.7.0 121 + version: 5.9.3 122 + 123 + packages/cli: 124 + dependencies: 125 + '@atbb/atproto': 126 + specifier: workspace:* 127 + version: link:../atproto 128 + '@atbb/db': 129 + specifier: workspace:* 130 + version: link:../db 131 + '@atproto/api': 132 + specifier: ^0.15.0 133 + version: 0.15.27 134 + '@inquirer/prompts': 135 + specifier: ^7.0.0 136 + version: 7.10.1(@types/node@22.19.9) 137 + citty: 138 + specifier: ^0.1.6 139 + version: 0.1.6 140 + consola: 141 + specifier: ^3.4.0 142 + version: 3.4.2 143 + drizzle-orm: 144 + specifier: ^0.45.1 145 + version: 0.45.1(postgres@3.4.8) 146 + postgres: 147 + specifier: ^3.4.8 148 + version: 3.4.8 149 + devDependencies: 150 + '@types/node': 151 + specifier: ^22.0.0 152 + version: 22.19.9 153 + tsx: 154 + specifier: ^4.0.0 155 + version: 4.21.0 156 + typescript: 157 + specifier: ^5.7.0 158 + version: 5.9.3 159 + vitest: 160 + specifier: ^3.0.0 161 + version: 3.2.4(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 106 162 107 163 packages/db: 108 164 dependencies: ··· 722 778 peerDependencies: 723 779 hono: ^4 724 780 781 + '@inquirer/ansi@1.0.2': 782 + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} 783 + engines: {node: '>=18'} 784 + 785 + '@inquirer/checkbox@4.3.2': 786 + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} 787 + engines: {node: '>=18'} 788 + peerDependencies: 789 + '@types/node': '>=18' 790 + peerDependenciesMeta: 791 + '@types/node': 792 + optional: true 793 + 794 + '@inquirer/confirm@5.1.21': 795 + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} 796 + engines: {node: '>=18'} 797 + peerDependencies: 798 + '@types/node': '>=18' 799 + peerDependenciesMeta: 800 + '@types/node': 801 + optional: true 802 + 803 + '@inquirer/core@10.3.2': 804 + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} 805 + engines: {node: '>=18'} 806 + peerDependencies: 807 + '@types/node': '>=18' 808 + peerDependenciesMeta: 809 + '@types/node': 810 + optional: true 811 + 812 + '@inquirer/editor@4.2.23': 813 + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} 814 + engines: {node: '>=18'} 815 + peerDependencies: 816 + '@types/node': '>=18' 817 + peerDependenciesMeta: 818 + '@types/node': 819 + optional: true 820 + 821 + '@inquirer/expand@4.0.23': 822 + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} 823 + engines: {node: '>=18'} 824 + peerDependencies: 825 + '@types/node': '>=18' 826 + peerDependenciesMeta: 827 + '@types/node': 828 + optional: true 829 + 830 + '@inquirer/external-editor@1.0.3': 831 + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} 832 + engines: {node: '>=18'} 833 + peerDependencies: 834 + '@types/node': '>=18' 835 + peerDependenciesMeta: 836 + '@types/node': 837 + optional: true 838 + 839 + '@inquirer/figures@1.0.15': 840 + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} 841 + engines: {node: '>=18'} 842 + 843 + '@inquirer/input@4.3.1': 844 + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} 845 + engines: {node: '>=18'} 846 + peerDependencies: 847 + '@types/node': '>=18' 848 + peerDependenciesMeta: 849 + '@types/node': 850 + optional: true 851 + 852 + '@inquirer/number@3.0.23': 853 + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} 854 + engines: {node: '>=18'} 855 + peerDependencies: 856 + '@types/node': '>=18' 857 + peerDependenciesMeta: 858 + '@types/node': 859 + optional: true 860 + 861 + '@inquirer/password@4.0.23': 862 + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} 863 + engines: {node: '>=18'} 864 + peerDependencies: 865 + '@types/node': '>=18' 866 + peerDependenciesMeta: 867 + '@types/node': 868 + optional: true 869 + 870 + '@inquirer/prompts@7.10.1': 871 + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} 872 + engines: {node: '>=18'} 873 + peerDependencies: 874 + '@types/node': '>=18' 875 + peerDependenciesMeta: 876 + '@types/node': 877 + optional: true 878 + 879 + '@inquirer/rawlist@4.1.11': 880 + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} 881 + engines: {node: '>=18'} 882 + peerDependencies: 883 + '@types/node': '>=18' 884 + peerDependenciesMeta: 885 + '@types/node': 886 + optional: true 887 + 888 + '@inquirer/search@3.2.2': 889 + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} 890 + engines: {node: '>=18'} 891 + peerDependencies: 892 + '@types/node': '>=18' 893 + peerDependenciesMeta: 894 + '@types/node': 895 + optional: true 896 + 897 + '@inquirer/select@4.4.2': 898 + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} 899 + engines: {node: '>=18'} 900 + peerDependencies: 901 + '@types/node': '>=18' 902 + peerDependenciesMeta: 903 + '@types/node': 904 + optional: true 905 + 906 + '@inquirer/type@3.0.10': 907 + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} 908 + engines: {node: '>=18'} 909 + peerDependencies: 910 + '@types/node': '>=18' 911 + peerDependenciesMeta: 912 + '@types/node': 913 + optional: true 914 + 725 915 '@isaacs/balanced-match@4.0.1': 726 916 resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} 727 917 engines: {node: 20 || >=22} ··· 985 1175 resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} 986 1176 engines: {node: '>=6.5'} 987 1177 1178 + ansi-regex@5.0.1: 1179 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 1180 + engines: {node: '>=8'} 1181 + 988 1182 ansi-styles@4.3.0: 989 1183 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 990 1184 engines: {node: '>=8'} ··· 1031 1225 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1032 1226 engines: {node: '>=10'} 1033 1227 1228 + chardet@2.1.1: 1229 + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} 1230 + 1034 1231 check-error@2.1.3: 1035 1232 resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} 1036 1233 engines: {node: '>= 16'} 1234 + 1235 + citty@0.1.6: 1236 + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} 1237 + 1238 + cli-width@4.1.0: 1239 + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} 1240 + engines: {node: '>= 12'} 1037 1241 1038 1242 code-block-writer@13.0.3: 1039 1243 resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} ··· 1049 1253 resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} 1050 1254 engines: {node: ^12.20.0 || >=14} 1051 1255 1256 + consola@3.4.2: 1257 + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} 1258 + engines: {node: ^14.18.0 || >=16.10.0} 1259 + 1052 1260 core-js@3.48.0: 1053 1261 resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} 1054 1262 ··· 1169 1377 sqlite3: 1170 1378 optional: true 1171 1379 1380 + emoji-regex@8.0.0: 1381 + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 1382 + 1172 1383 es-module-lexer@1.7.0: 1173 1384 resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} 1174 1385 ··· 1252 1463 resolution: {integrity: sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==} 1253 1464 engines: {node: '>=16.9.0'} 1254 1465 1466 + iconv-lite@0.7.2: 1467 + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} 1468 + engines: {node: '>=0.10.0'} 1469 + 1255 1470 ieee754@1.2.1: 1256 1471 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 1257 1472 ··· 1259 1474 resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} 1260 1475 engines: {node: '>= 10'} 1261 1476 1477 + is-fullwidth-code-point@3.0.0: 1478 + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 1479 + engines: {node: '>=8'} 1480 + 1262 1481 isexe@2.0.0: 1263 1482 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 1264 1483 ··· 1363 1582 multiformats@9.9.0: 1364 1583 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1365 1584 1585 + mute-stream@2.0.0: 1586 + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} 1587 + engines: {node: ^18.17.0 || >=20.5.0} 1588 + 1366 1589 nanoid@3.3.11: 1367 1590 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 1368 1591 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} ··· 1467 1690 resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} 1468 1691 engines: {node: '>=10'} 1469 1692 1693 + safer-buffer@2.1.2: 1694 + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 1695 + 1470 1696 shebang-command@2.0.0: 1471 1697 resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 1472 1698 engines: {node: '>=8'} ··· 1506 1732 std-env@3.10.0: 1507 1733 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 1508 1734 1735 + string-width@4.2.3: 1736 + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 1737 + engines: {node: '>=8'} 1738 + 1509 1739 string_decoder@1.3.0: 1510 1740 resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 1511 1741 1742 + strip-ansi@6.0.1: 1743 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 1744 + engines: {node: '>=8'} 1745 + 1512 1746 strip-literal@3.1.0: 1513 1747 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 1514 1748 ··· 1744 1978 engines: {node: '>=8'} 1745 1979 hasBin: true 1746 1980 1981 + wrap-ansi@6.2.0: 1982 + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} 1983 + engines: {node: '>=8'} 1984 + 1747 1985 yaml@2.8.2: 1748 1986 resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} 1749 1987 engines: {node: '>= 14.6'} ··· 1751 1989 1752 1990 yesno@0.4.0: 1753 1991 resolution: {integrity: sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==} 1992 + 1993 + yoctocolors-cjs@2.1.3: 1994 + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} 1995 + engines: {node: '>=18'} 1754 1996 1755 1997 zod@3.25.76: 1756 1998 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} ··· 2198 2440 dependencies: 2199 2441 hono: 4.11.8 2200 2442 2443 + '@inquirer/ansi@1.0.2': {} 2444 + 2445 + '@inquirer/checkbox@4.3.2(@types/node@22.19.9)': 2446 + dependencies: 2447 + '@inquirer/ansi': 1.0.2 2448 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2449 + '@inquirer/figures': 1.0.15 2450 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2451 + yoctocolors-cjs: 2.1.3 2452 + optionalDependencies: 2453 + '@types/node': 22.19.9 2454 + 2455 + '@inquirer/confirm@5.1.21(@types/node@22.19.9)': 2456 + dependencies: 2457 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2458 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2459 + optionalDependencies: 2460 + '@types/node': 22.19.9 2461 + 2462 + '@inquirer/core@10.3.2(@types/node@22.19.9)': 2463 + dependencies: 2464 + '@inquirer/ansi': 1.0.2 2465 + '@inquirer/figures': 1.0.15 2466 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2467 + cli-width: 4.1.0 2468 + mute-stream: 2.0.0 2469 + signal-exit: 4.1.0 2470 + wrap-ansi: 6.2.0 2471 + yoctocolors-cjs: 2.1.3 2472 + optionalDependencies: 2473 + '@types/node': 22.19.9 2474 + 2475 + '@inquirer/editor@4.2.23(@types/node@22.19.9)': 2476 + dependencies: 2477 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2478 + '@inquirer/external-editor': 1.0.3(@types/node@22.19.9) 2479 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2480 + optionalDependencies: 2481 + '@types/node': 22.19.9 2482 + 2483 + '@inquirer/expand@4.0.23(@types/node@22.19.9)': 2484 + dependencies: 2485 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2486 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2487 + yoctocolors-cjs: 2.1.3 2488 + optionalDependencies: 2489 + '@types/node': 22.19.9 2490 + 2491 + '@inquirer/external-editor@1.0.3(@types/node@22.19.9)': 2492 + dependencies: 2493 + chardet: 2.1.1 2494 + iconv-lite: 0.7.2 2495 + optionalDependencies: 2496 + '@types/node': 22.19.9 2497 + 2498 + '@inquirer/figures@1.0.15': {} 2499 + 2500 + '@inquirer/input@4.3.1(@types/node@22.19.9)': 2501 + dependencies: 2502 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2503 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2504 + optionalDependencies: 2505 + '@types/node': 22.19.9 2506 + 2507 + '@inquirer/number@3.0.23(@types/node@22.19.9)': 2508 + dependencies: 2509 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2510 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2511 + optionalDependencies: 2512 + '@types/node': 22.19.9 2513 + 2514 + '@inquirer/password@4.0.23(@types/node@22.19.9)': 2515 + dependencies: 2516 + '@inquirer/ansi': 1.0.2 2517 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2518 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2519 + optionalDependencies: 2520 + '@types/node': 22.19.9 2521 + 2522 + '@inquirer/prompts@7.10.1(@types/node@22.19.9)': 2523 + dependencies: 2524 + '@inquirer/checkbox': 4.3.2(@types/node@22.19.9) 2525 + '@inquirer/confirm': 5.1.21(@types/node@22.19.9) 2526 + '@inquirer/editor': 4.2.23(@types/node@22.19.9) 2527 + '@inquirer/expand': 4.0.23(@types/node@22.19.9) 2528 + '@inquirer/input': 4.3.1(@types/node@22.19.9) 2529 + '@inquirer/number': 3.0.23(@types/node@22.19.9) 2530 + '@inquirer/password': 4.0.23(@types/node@22.19.9) 2531 + '@inquirer/rawlist': 4.1.11(@types/node@22.19.9) 2532 + '@inquirer/search': 3.2.2(@types/node@22.19.9) 2533 + '@inquirer/select': 4.4.2(@types/node@22.19.9) 2534 + optionalDependencies: 2535 + '@types/node': 22.19.9 2536 + 2537 + '@inquirer/rawlist@4.1.11(@types/node@22.19.9)': 2538 + dependencies: 2539 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2540 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2541 + yoctocolors-cjs: 2.1.3 2542 + optionalDependencies: 2543 + '@types/node': 22.19.9 2544 + 2545 + '@inquirer/search@3.2.2(@types/node@22.19.9)': 2546 + dependencies: 2547 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2548 + '@inquirer/figures': 1.0.15 2549 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2550 + yoctocolors-cjs: 2.1.3 2551 + optionalDependencies: 2552 + '@types/node': 22.19.9 2553 + 2554 + '@inquirer/select@4.4.2(@types/node@22.19.9)': 2555 + dependencies: 2556 + '@inquirer/ansi': 1.0.2 2557 + '@inquirer/core': 10.3.2(@types/node@22.19.9) 2558 + '@inquirer/figures': 1.0.15 2559 + '@inquirer/type': 3.0.10(@types/node@22.19.9) 2560 + yoctocolors-cjs: 2.1.3 2561 + optionalDependencies: 2562 + '@types/node': 22.19.9 2563 + 2564 + '@inquirer/type@3.0.10(@types/node@22.19.9)': 2565 + optionalDependencies: 2566 + '@types/node': 22.19.9 2567 + 2201 2568 '@isaacs/balanced-match@4.0.1': {} 2202 2569 2203 2570 '@isaacs/brace-expansion@5.0.1': ··· 2421 2788 dependencies: 2422 2789 event-target-shim: 5.0.1 2423 2790 2791 + ansi-regex@5.0.1: {} 2792 + 2424 2793 ansi-styles@4.3.0: 2425 2794 dependencies: 2426 2795 color-convert: 2.0.1 ··· 2462 2831 dependencies: 2463 2832 ansi-styles: 4.3.0 2464 2833 supports-color: 7.2.0 2834 + 2835 + chardet@2.1.1: {} 2465 2836 2466 2837 check-error@2.1.3: {} 2467 2838 2839 + citty@0.1.6: 2840 + dependencies: 2841 + consola: 3.4.2 2842 + 2843 + cli-width@4.1.0: {} 2844 + 2468 2845 code-block-writer@13.0.3: {} 2469 2846 2470 2847 color-convert@2.0.1: ··· 2474 2851 color-name@1.1.4: {} 2475 2852 2476 2853 commander@9.5.0: {} 2854 + 2855 + consola@3.4.2: {} 2477 2856 2478 2857 core-js@3.48.0: {} 2479 2858 ··· 2503 2882 drizzle-orm@0.45.1(postgres@3.4.8): 2504 2883 optionalDependencies: 2505 2884 postgres: 3.4.8 2885 + 2886 + emoji-regex@8.0.0: {} 2506 2887 2507 2888 es-module-lexer@1.7.0: {} 2508 2889 ··· 2641 3022 2642 3023 hono@4.11.8: {} 2643 3024 3025 + iconv-lite@0.7.2: 3026 + dependencies: 3027 + safer-buffer: 2.1.2 3028 + 2644 3029 ieee754@1.2.1: {} 2645 3030 2646 3031 ipaddr.js@2.3.0: {} 3032 + 3033 + is-fullwidth-code-point@3.0.0: {} 2647 3034 2648 3035 isexe@2.0.0: {} 2649 3036 ··· 2725 3112 multiformats@13.4.2: {} 2726 3113 2727 3114 multiformats@9.9.0: {} 3115 + 3116 + mute-stream@2.0.0: {} 2728 3117 2729 3118 nanoid@3.3.11: {} 2730 3119 ··· 2850 3239 2851 3240 safe-stable-stringify@2.5.0: {} 2852 3241 3242 + safer-buffer@2.1.2: {} 3243 + 2853 3244 shebang-command@2.0.0: 2854 3245 dependencies: 2855 3246 shebang-regex: 3.0.0 ··· 2878 3269 stackback@0.0.2: {} 2879 3270 2880 3271 std-env@3.10.0: {} 3272 + 3273 + string-width@4.2.3: 3274 + dependencies: 3275 + emoji-regex: 8.0.0 3276 + is-fullwidth-code-point: 3.0.0 3277 + strip-ansi: 6.0.1 2881 3278 2882 3279 string_decoder@1.3.0: 2883 3280 dependencies: 2884 3281 safe-buffer: 5.2.1 2885 3282 3283 + strip-ansi@6.0.1: 3284 + dependencies: 3285 + ansi-regex: 5.0.1 3286 + 2886 3287 strip-literal@3.1.0: 2887 3288 dependencies: 2888 3289 js-tokens: 9.0.1 ··· 3099 3500 siginfo: 2.0.0 3100 3501 stackback: 0.0.2 3101 3502 3503 + wrap-ansi@6.2.0: 3504 + dependencies: 3505 + ansi-styles: 4.3.0 3506 + string-width: 4.2.3 3507 + strip-ansi: 6.0.1 3508 + 3102 3509 yaml@2.8.2: {} 3103 3510 3104 3511 yesno@0.4.0: {} 3512 + 3513 + yoctocolors-cjs@2.1.3: {} 3105 3514 3106 3515 zod@3.25.76: {}