import { defineCommand } from "citty"; import consola from "consola"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { ForumAgent } from "@atbb/atproto"; import { loadCliConfig } from "../lib/config.js"; import { logger } from "../lib/logger.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PRESET_DIR = join(__dirname, "../../../../apps/web/src/styles/presets"); const PRESETS = [ { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, ] as const; /** Stable JSON string for comparison — sorted token keys, ignores ordering differences. */ function stableTokensJson(tokens: Record): string { return JSON.stringify(Object.fromEntries(Object.entries(tokens).sort())); } /** True if the existing PDS record has the same content as the local preset. */ function isRecordCurrent( existing: Record, preset: { name: string; colorScheme: string }, tokens: Record ): boolean { return ( existing.name === preset.name && existing.colorScheme === preset.colorScheme && stableTokensJson(existing.tokens as Record) === stableTokensJson(tokens) ); } /** * Authenticate using ForumAgent and return the raw agent + DID. * Theme commands only need PDS_URL, FORUM_HANDLE, FORUM_PASSWORD — * DATABASE_URL and FORUM_DID are not required. */ async function authenticate(config: ReturnType) { const themeEnvMissing = config.missing.filter( (v) => v !== "DATABASE_URL" && v !== "FORUM_DID" ); if (themeEnvMissing.length > 0) { consola.error("Missing required environment variables:"); for (const name of themeEnvMissing) consola.error(` - ${name}`); process.exit(1); } consola.start("Authenticating..."); const forumAgent = new ForumAgent( config.pdsUrl, config.forumHandle, config.forumPassword, logger ); try { await forumAgent.initialize(); } catch (error) { consola.error( `Failed to reach PDS (${config.pdsUrl}):`, error instanceof Error ? error.message : String(error) ); try { await forumAgent.shutdown(); } catch {} process.exit(1); } if (!forumAgent.isAuthenticated()) { const status = forumAgent.getStatus(); consola.error(`Authentication failed: ${status.error}`); await forumAgent.shutdown(); process.exit(1); } const agent = forumAgent.getAgent()!; const did = agent.session?.did; if (!did) { consola.error("Login succeeded but session has no DID"); await forumAgent.shutdown(); process.exit(1); } consola.success(`Authenticated as ${config.forumHandle} (${did})`); return { agent, did, forumAgent }; } // ── bootstrap-local ────────────────────────────────────────────────────────── const bootstrapLocalCommand = defineCommand({ meta: { name: "bootstrap-local", description: "Mirror built-in preset themes to your own PDS — zero external dependencies", }, args: { "dry-run": { type: "boolean", description: "Show what would be written without making any changes", default: false, }, }, async run({ args }) { const isDryRun = args["dry-run"]; consola.box("atBB — Bootstrap Local Presets" + (isDryRun ? " [dry-run]" : "")); const config = loadCliConfig(); const { agent, did, forumAgent } = await authenticate(config); consola.info(`Writing ${PRESETS.length} preset records to ${config.pdsUrl}`); if (isDryRun) consola.warn("Dry-run: no changes will be made."); consola.log(""); const now = new Date().toISOString(); const localUris: Array<{ rkey: string; uri: string }> = []; for (const preset of PRESETS) { const tokens = JSON.parse( readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") ) as Record; const uri = `at://${did}/space.atbb.forum.theme/${preset.rkey}`; localUris.push({ rkey: preset.rkey, uri }); if (isDryRun) { consola.info(` ~ ${preset.name} — would write ${uri}`); continue; } await agent.com.atproto.repo.putRecord({ repo: did, collection: "space.atbb.forum.theme", rkey: preset.rkey, record: { $type: "space.atbb.forum.theme", name: preset.name, colorScheme: preset.colorScheme, tokens, createdAt: now, updatedAt: now, }, }); consola.success(` ${preset.name}`); } const lightUri = localUris.find((t) => t.rkey === "neobrutal-light")!.uri; const darkUri = localUris.find((t) => t.rkey === "neobrutal-dark")!.uri; const available = localUris.map((t) => ({ uri: t.uri })); consola.log(""); // Show existing themePolicy before overwriting so operators can see what will change try { const existing = await agent.com.atproto.repo.getRecord({ repo: did, collection: "space.atbb.forum.themePolicy", rkey: "self", }); const rec = existing.data.value as Record; const existingLight = (rec.defaultLightTheme as Record | undefined)?.uri; const existingDark = (rec.defaultDarkTheme as Record | undefined)?.uri; consola.info("Existing themePolicy:"); consola.info(` defaultLightTheme: ${existingLight ?? "(none)"}`); consola.info(` defaultDarkTheme: ${existingDark ?? "(none)"}`); } catch { consola.info("No existing themePolicy — will create."); } if (isDryRun) { consola.info(` ~ themePolicy — would write ${available.length} local refs`); consola.info(` defaultLightTheme: ${lightUri}`); consola.info(` defaultDarkTheme: ${darkUri}`); } else { await agent.com.atproto.repo.putRecord({ repo: did, collection: "space.atbb.forum.themePolicy", rkey: "self", record: { $type: "space.atbb.forum.themePolicy", availableThemes: available, defaultLightTheme: { uri: lightUri }, defaultDarkTheme: { uri: darkUri }, allowUserChoice: true, updatedAt: now, }, }); consola.success("themePolicy written"); consola.info(` defaultLightTheme: ${lightUri}`); consola.info(` defaultDarkTheme: ${darkUri}`); } await forumAgent.shutdown(); consola.log(""); consola.box( "Done — this forum now uses only local preset refs (no atbb.space dependency).\n" + "You can still customize presets in the admin theme editor." ); }, }); // ── publish-canonical ──────────────────────────────────────────────────────── const publishCanonicalCommand = defineCommand({ meta: { name: "publish-canonical", description: "[atbb.space only] Publish built-in preset themes to the canonical PDS. " + "Safe to re-run — uses upsert semantics, skips unchanged presets.", }, args: { "dry-run": { type: "boolean", description: "Show what would be written without making any changes", default: false, }, }, async run({ args }) { const isDryRun = args["dry-run"]; consola.box("atBB — Publish Canonical Presets" + (isDryRun ? " [dry-run]" : "")); const config = loadCliConfig(); const { agent, did, forumAgent } = await authenticate(config); consola.info(`Publishing ${PRESETS.length} presets to ${config.pdsUrl}`); if (isDryRun) consola.warn("Dry-run: no changes will be made."); consola.log(""); const now = new Date().toISOString(); let written = 0; let skipped = 0; for (const preset of PRESETS) { const tokens = JSON.parse( readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") ) as Record; // Fetch existing record to check for changes and preserve createdAt let existingCreatedAt: string | null = null; let alreadyCurrent = false; try { const res = await agent.com.atproto.repo.getRecord({ repo: did, collection: "space.atbb.forum.theme", rkey: preset.rkey, }); const existing = res.data.value as Record; existingCreatedAt = (existing.createdAt as string) ?? null; if (isRecordCurrent(existing, preset, tokens)) alreadyCurrent = true; } catch (err: unknown) { // Only swallow 404 — record doesn't exist yet and will be created. // Re-throw anything else (network errors, auth failures, etc.). const status = (err as Record).status; if (status !== 404) throw err; } if (alreadyCurrent) { consola.success(`${preset.name} — unchanged`); skipped++; continue; } const action = existingCreatedAt ? "updated" : "created"; if (isDryRun) { consola.info(` ~ ${preset.name} — would ${action}`); written++; continue; } const record: Record = { $type: "space.atbb.forum.theme", name: preset.name, colorScheme: preset.colorScheme, tokens, createdAt: existingCreatedAt ?? now, }; // Only set updatedAt on updates, not initial creates if (existingCreatedAt) record.updatedAt = now; await agent.com.atproto.repo.putRecord({ repo: did, collection: "space.atbb.forum.theme", rkey: preset.rkey, record, }); consola.success(`${preset.name} — ${action}`); written++; } await forumAgent.shutdown(); consola.log(""); consola.info(`Done. ${written} written, ${skipped} unchanged.`); }, }); // ── theme command group ────────────────────────────────────────────────────── export const themeCommand = defineCommand({ meta: { name: "theme", description: "Manage forum themes and preset publishing", }, subCommands: { "bootstrap-local": bootstrapLocalCommand, "publish-canonical": publishCanonicalCommand, }, });