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 COPY apps/appview/package.json ./apps/appview/ 16 COPY apps/web/package.json ./apps/web/ 17 COPY packages/db/package.json ./packages/db/ 18 COPY packages/lexicon/package.json ./packages/lexicon/ 19 20 # Install all dependencies (including dev dependencies for build) ··· 41 COPY apps/appview/package.json ./apps/appview/ 42 COPY apps/web/package.json ./apps/web/ 43 COPY packages/db/package.json ./packages/db/ 44 COPY packages/lexicon/package.json ./packages/lexicon/ 45 46 # Install pnpm and production dependencies only ··· 61 COPY --from=builder /build/apps/appview/dist ./apps/appview/dist 62 COPY --from=builder /build/apps/web/dist ./apps/web/dist 63 COPY --from=builder /build/packages/db/dist ./packages/db/dist 64 COPY --from=builder /build/packages/lexicon/dist ./packages/lexicon/dist 65 66 # Copy migration files for drizzle-kit
··· 15 COPY apps/appview/package.json ./apps/appview/ 16 COPY apps/web/package.json ./apps/web/ 17 COPY packages/db/package.json ./packages/db/ 18 + COPY packages/atproto/package.json ./packages/atproto/ 19 + COPY packages/cli/package.json ./packages/cli/ 20 COPY packages/lexicon/package.json ./packages/lexicon/ 21 22 # Install all dependencies (including dev dependencies for build) ··· 43 COPY apps/appview/package.json ./apps/appview/ 44 COPY apps/web/package.json ./apps/web/ 45 COPY packages/db/package.json ./packages/db/ 46 + COPY packages/atproto/package.json ./packages/atproto/ 47 + COPY packages/cli/package.json ./packages/cli/ 48 COPY packages/lexicon/package.json ./packages/lexicon/ 49 50 # Install pnpm and production dependencies only ··· 65 COPY --from=builder /build/apps/appview/dist ./apps/appview/dist 66 COPY --from=builder /build/apps/web/dist ./apps/web/dist 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 70 COPY --from=builder /build/packages/lexicon/dist ./packages/lexicon/dist 71 72 # Copy migration files for drizzle-kit
+1
apps/appview/package.json
··· 15 "db:migrate": "drizzle-kit migrate" 16 }, 17 "dependencies": { 18 "@atbb/db": "workspace:*", 19 "@atbb/lexicon": "workspace:*", 20 "@atproto/api": "^0.15.0",
··· 15 "db:migrate": "drizzle-kit migrate" 16 }, 17 "dependencies": { 18 + "@atbb/atproto": "workspace:*", 19 "@atbb/db": "workspace:*", 20 "@atbb/lexicon": "workspace:*", 21 "@atproto/api": "^0.15.0",
+1 -1
apps/appview/src/lib/__tests__/app-context.test.ts
··· 29 CookieSessionStore: vi.fn(() => ({ destroy: vi.fn() })), 30 })); 31 32 - vi.mock("../forum-agent.js", () => ({ 33 ForumAgent: vi.fn(() => ({ 34 initialize: vi.fn(), 35 shutdown: vi.fn(),
··· 29 CookieSessionStore: vi.fn(() => ({ destroy: vi.fn() })), 30 })); 31 32 + vi.mock("@atbb/atproto", () => ({ 33 ForumAgent: vi.fn(() => ({ 34 initialize: vi.fn(), 35 shutdown: vi.fn(),
+1 -1
apps/appview/src/lib/__tests__/forum-agent.test.ts packages/atproto/src/__tests__/forum-agent.test.ts
··· 18 login: mockLogin, 19 session: null, 20 }; 21 - (AtpAgent as any).mockImplementation(() => mockAgent); 22 }); 23 24 afterEach(async () => {
··· 18 login: mockLogin, 19 session: null, 20 }; 21 + (AtpAgent as any).mockImplementation(function () { return mockAgent; }); 22 }); 23 24 afterEach(async () => {
+66 -1
apps/appview/src/lib/__tests__/membership.test.ts
··· 2 import { createMembershipForUser } from "../membership.js"; 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 import { memberships, users, forums } from "@atbb/db"; 5 - import { eq } from "drizzle-orm"; 6 7 describe("createMembershipForUser", () => { 8 let ctx: TestContext; ··· 211 testDid 212 ); 213 expect(result2.created).toBe(false); 214 }); 215 });
··· 2 import { createMembershipForUser } from "../membership.js"; 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 import { memberships, users, forums } from "@atbb/db"; 5 + import { eq, and } from "drizzle-orm"; 6 7 describe("createMembershipForUser", () => { 8 let ctx: TestContext; ··· 211 testDid 212 ); 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"); 279 }); 280 });
+1 -1
apps/appview/src/lib/app-context.ts
··· 4 import { NodeOAuthClient } from "@atproto/oauth-client-node"; 5 import { OAuthStateStore, OAuthSessionStore } from "./oauth-stores.js"; 6 import { CookieSessionStore } from "./cookie-session-store.js"; 7 - import { ForumAgent } from "./forum-agent.js"; 8 import type { AppConfig } from "./config.js"; 9 10 /**
··· 4 import { NodeOAuthClient } from "@atproto/oauth-client-node"; 5 import { OAuthStateStore, OAuthSessionStore } from "./oauth-stores.js"; 6 import { CookieSessionStore } from "./cookie-session-store.js"; 7 + import { ForumAgent } from "@atbb/atproto"; 8 import type { AppConfig } from "./config.js"; 9 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 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 - } 40 41 export type ForumAgentStatus = 42 | "initializing"
··· 1 import { AtpAgent } from "@atproto/api"; 2 + import { isAuthError, isNetworkError } from "./errors.js"; 3 4 export type ForumAgentStatus = 5 | "initializing"
+57 -3
apps/appview/src/lib/membership.ts
··· 30 .limit(1); 31 32 if (existing.length > 0) { 33 return { created: false }; 34 } 35 36 - // Create membership record on user's PDS 37 const rkey = TID.nextStr(); 38 const now = new Date().toISOString(); 39 ··· 44 record: { 45 $type: "space.atbb.membership", 46 forum: { 47 - forum: { uri: forumUri, cid: forum.cid }, 48 }, 49 createdAt: now, 50 joinedAt: now, 51 - // role field omitted - defaults to guest/member permissions 52 }, 53 }); 54 55 return { created: true, uri: result.data.uri, cid: result.data.cid }; 56 }
··· 30 .limit(1); 31 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 + 42 return { created: false }; 43 } 44 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 }> { 54 const rkey = TID.nextStr(); 55 const now = new Date().toISOString(); 56 ··· 61 record: { 62 $type: "space.atbb.membership", 63 forum: { 64 + forum: { uri: forumUri, cid: forumCid }, 65 }, 66 createdAt: now, 67 joinedAt: now, 68 }, 69 }); 70 71 return { created: true, uri: result.data.uri, cid: result.data.cid }; 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 27 apps/appview: 28 dependencies: 29 '@atbb/db': 30 specifier: workspace:* 31 version: link:../../packages/db ··· 103 typescript: 104 specifier: ^5.7.0 105 version: 5.9.3 106 107 packages/db: 108 dependencies: ··· 722 peerDependencies: 723 hono: ^4 724 725 '@isaacs/balanced-match@4.0.1': 726 resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} 727 engines: {node: 20 || >=22} ··· 985 resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} 986 engines: {node: '>=6.5'} 987 988 ansi-styles@4.3.0: 989 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 990 engines: {node: '>=8'} ··· 1031 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1032 engines: {node: '>=10'} 1033 1034 check-error@2.1.3: 1035 resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} 1036 engines: {node: '>= 16'} 1037 1038 code-block-writer@13.0.3: 1039 resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} ··· 1049 resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} 1050 engines: {node: ^12.20.0 || >=14} 1051 1052 core-js@3.48.0: 1053 resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} 1054 ··· 1169 sqlite3: 1170 optional: true 1171 1172 es-module-lexer@1.7.0: 1173 resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} 1174 ··· 1252 resolution: {integrity: sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==} 1253 engines: {node: '>=16.9.0'} 1254 1255 ieee754@1.2.1: 1256 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 1257 ··· 1259 resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} 1260 engines: {node: '>= 10'} 1261 1262 isexe@2.0.0: 1263 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 1264 ··· 1363 multiformats@9.9.0: 1364 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1365 1366 nanoid@3.3.11: 1367 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 1368 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} ··· 1467 resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} 1468 engines: {node: '>=10'} 1469 1470 shebang-command@2.0.0: 1471 resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 1472 engines: {node: '>=8'} ··· 1506 std-env@3.10.0: 1507 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 1508 1509 string_decoder@1.3.0: 1510 resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 1511 1512 strip-literal@3.1.0: 1513 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 1514 ··· 1744 engines: {node: '>=8'} 1745 hasBin: true 1746 1747 yaml@2.8.2: 1748 resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} 1749 engines: {node: '>= 14.6'} ··· 1751 1752 yesno@0.4.0: 1753 resolution: {integrity: sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==} 1754 1755 zod@3.25.76: 1756 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} ··· 2198 dependencies: 2199 hono: 4.11.8 2200 2201 '@isaacs/balanced-match@4.0.1': {} 2202 2203 '@isaacs/brace-expansion@5.0.1': ··· 2421 dependencies: 2422 event-target-shim: 5.0.1 2423 2424 ansi-styles@4.3.0: 2425 dependencies: 2426 color-convert: 2.0.1 ··· 2462 dependencies: 2463 ansi-styles: 4.3.0 2464 supports-color: 7.2.0 2465 2466 check-error@2.1.3: {} 2467 2468 code-block-writer@13.0.3: {} 2469 2470 color-convert@2.0.1: ··· 2474 color-name@1.1.4: {} 2475 2476 commander@9.5.0: {} 2477 2478 core-js@3.48.0: {} 2479 ··· 2503 drizzle-orm@0.45.1(postgres@3.4.8): 2504 optionalDependencies: 2505 postgres: 3.4.8 2506 2507 es-module-lexer@1.7.0: {} 2508 ··· 2641 2642 hono@4.11.8: {} 2643 2644 ieee754@1.2.1: {} 2645 2646 ipaddr.js@2.3.0: {} 2647 2648 isexe@2.0.0: {} 2649 ··· 2725 multiformats@13.4.2: {} 2726 2727 multiformats@9.9.0: {} 2728 2729 nanoid@3.3.11: {} 2730 ··· 2850 2851 safe-stable-stringify@2.5.0: {} 2852 2853 shebang-command@2.0.0: 2854 dependencies: 2855 shebang-regex: 3.0.0 ··· 2878 stackback@0.0.2: {} 2879 2880 std-env@3.10.0: {} 2881 2882 string_decoder@1.3.0: 2883 dependencies: 2884 safe-buffer: 5.2.1 2885 2886 strip-literal@3.1.0: 2887 dependencies: 2888 js-tokens: 9.0.1 ··· 3099 siginfo: 2.0.0 3100 stackback: 0.0.2 3101 3102 yaml@2.8.2: {} 3103 3104 yesno@0.4.0: {} 3105 3106 zod@3.25.76: {}
··· 26 27 apps/appview: 28 dependencies: 29 + '@atbb/atproto': 30 + specifier: workspace:* 31 + version: link:../../packages/atproto 32 '@atbb/db': 33 specifier: workspace:* 34 version: link:../../packages/db ··· 106 typescript: 107 specifier: ^5.7.0 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) 162 163 packages/db: 164 dependencies: ··· 778 peerDependencies: 779 hono: ^4 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 + 915 '@isaacs/balanced-match@4.0.1': 916 resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} 917 engines: {node: 20 || >=22} ··· 1175 resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} 1176 engines: {node: '>=6.5'} 1177 1178 + ansi-regex@5.0.1: 1179 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 1180 + engines: {node: '>=8'} 1181 + 1182 ansi-styles@4.3.0: 1183 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 1184 engines: {node: '>=8'} ··· 1225 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1226 engines: {node: '>=10'} 1227 1228 + chardet@2.1.1: 1229 + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} 1230 + 1231 check-error@2.1.3: 1232 resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} 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'} 1241 1242 code-block-writer@13.0.3: 1243 resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} ··· 1253 resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} 1254 engines: {node: ^12.20.0 || >=14} 1255 1256 + consola@3.4.2: 1257 + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} 1258 + engines: {node: ^14.18.0 || >=16.10.0} 1259 + 1260 core-js@3.48.0: 1261 resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} 1262 ··· 1377 sqlite3: 1378 optional: true 1379 1380 + emoji-regex@8.0.0: 1381 + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 1382 + 1383 es-module-lexer@1.7.0: 1384 resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} 1385 ··· 1463 resolution: {integrity: sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==} 1464 engines: {node: '>=16.9.0'} 1465 1466 + iconv-lite@0.7.2: 1467 + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} 1468 + engines: {node: '>=0.10.0'} 1469 + 1470 ieee754@1.2.1: 1471 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 1472 ··· 1474 resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} 1475 engines: {node: '>= 10'} 1476 1477 + is-fullwidth-code-point@3.0.0: 1478 + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 1479 + engines: {node: '>=8'} 1480 + 1481 isexe@2.0.0: 1482 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 1483 ··· 1582 multiformats@9.9.0: 1583 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1584 1585 + mute-stream@2.0.0: 1586 + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} 1587 + engines: {node: ^18.17.0 || >=20.5.0} 1588 + 1589 nanoid@3.3.11: 1590 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 1591 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} ··· 1690 resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} 1691 engines: {node: '>=10'} 1692 1693 + safer-buffer@2.1.2: 1694 + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 1695 + 1696 shebang-command@2.0.0: 1697 resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 1698 engines: {node: '>=8'} ··· 1732 std-env@3.10.0: 1733 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 1734 1735 + string-width@4.2.3: 1736 + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 1737 + engines: {node: '>=8'} 1738 + 1739 string_decoder@1.3.0: 1740 resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 1741 1742 + strip-ansi@6.0.1: 1743 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 1744 + engines: {node: '>=8'} 1745 + 1746 strip-literal@3.1.0: 1747 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 1748 ··· 1978 engines: {node: '>=8'} 1979 hasBin: true 1980 1981 + wrap-ansi@6.2.0: 1982 + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} 1983 + engines: {node: '>=8'} 1984 + 1985 yaml@2.8.2: 1986 resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} 1987 engines: {node: '>= 14.6'} ··· 1989 1990 yesno@0.4.0: 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'} 1996 1997 zod@3.25.76: 1998 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} ··· 2440 dependencies: 2441 hono: 4.11.8 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 + 2568 '@isaacs/balanced-match@4.0.1': {} 2569 2570 '@isaacs/brace-expansion@5.0.1': ··· 2788 dependencies: 2789 event-target-shim: 5.0.1 2790 2791 + ansi-regex@5.0.1: {} 2792 + 2793 ansi-styles@4.3.0: 2794 dependencies: 2795 color-convert: 2.0.1 ··· 2831 dependencies: 2832 ansi-styles: 4.3.0 2833 supports-color: 7.2.0 2834 + 2835 + chardet@2.1.1: {} 2836 2837 check-error@2.1.3: {} 2838 2839 + citty@0.1.6: 2840 + dependencies: 2841 + consola: 3.4.2 2842 + 2843 + cli-width@4.1.0: {} 2844 + 2845 code-block-writer@13.0.3: {} 2846 2847 color-convert@2.0.1: ··· 2851 color-name@1.1.4: {} 2852 2853 commander@9.5.0: {} 2854 + 2855 + consola@3.4.2: {} 2856 2857 core-js@3.48.0: {} 2858 ··· 2882 drizzle-orm@0.45.1(postgres@3.4.8): 2883 optionalDependencies: 2884 postgres: 3.4.8 2885 + 2886 + emoji-regex@8.0.0: {} 2887 2888 es-module-lexer@1.7.0: {} 2889 ··· 3022 3023 hono@4.11.8: {} 3024 3025 + iconv-lite@0.7.2: 3026 + dependencies: 3027 + safer-buffer: 2.1.2 3028 + 3029 ieee754@1.2.1: {} 3030 3031 ipaddr.js@2.3.0: {} 3032 + 3033 + is-fullwidth-code-point@3.0.0: {} 3034 3035 isexe@2.0.0: {} 3036 ··· 3112 multiformats@13.4.2: {} 3113 3114 multiformats@9.9.0: {} 3115 + 3116 + mute-stream@2.0.0: {} 3117 3118 nanoid@3.3.11: {} 3119 ··· 3239 3240 safe-stable-stringify@2.5.0: {} 3241 3242 + safer-buffer@2.1.2: {} 3243 + 3244 shebang-command@2.0.0: 3245 dependencies: 3246 shebang-regex: 3.0.0 ··· 3269 stackback@0.0.2: {} 3270 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 3278 3279 string_decoder@1.3.0: 3280 dependencies: 3281 safe-buffer: 5.2.1 3282 3283 + strip-ansi@6.0.1: 3284 + dependencies: 3285 + ansi-regex: 5.0.1 3286 + 3287 strip-literal@3.1.0: 3288 dependencies: 3289 js-tokens: 9.0.1 ··· 3500 siginfo: 2.0.0 3501 stackback: 0.0.2 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 + 3509 yaml@2.8.2: {} 3510 3511 yesno@0.4.0: {} 3512 + 3513 + yoctocolors-cjs@2.1.3: {} 3514 3515 zod@3.25.76: {}