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

refactor(cli): move theme preset scripts into atbb CLI as theme subcommands

Moves `publish-presets.ts` and `bootstrap-local-presets.ts` from
`apps/appview/scripts/` into the `@atbb/cli` package as
`atbb theme publish-canonical` and `atbb theme bootstrap-local`.

Both commands sit alongside the existing init/category/board commands
and reuse the CLI's ForumAgent auth infrastructure. The appview npm
scripts that wrapped the old scripts are removed.

+312 -296
+1 -3
apps/appview/package.json
··· 15 15 "db:migrate": "drizzle-kit migrate --config=drizzle.postgres.config.ts", 16 16 "db:generate:sqlite": "drizzle-kit generate --config=drizzle.sqlite.config.ts", 17 17 "db:migrate:sqlite": "drizzle-kit migrate --config=drizzle.sqlite.config.ts", 18 - "migrate-permissions": "tsx --env-file=../../.env scripts/migrate-permissions.ts", 19 - "publish-presets": "tsx --env-file=../../.env scripts/publish-presets.ts", 20 - "bootstrap-local-presets": "tsx --env-file=../../.env scripts/bootstrap-local-presets.ts" 18 + "migrate-permissions": "tsx --env-file=../../.env scripts/migrate-permissions.ts" 21 19 }, 22 20 "dependencies": { 23 21 "@atbb/atproto": "workspace:*",
-146
apps/appview/scripts/bootstrap-local-presets.ts
··· 1 - /** 2 - * Escape-hatch script: writes all built-in preset theme records to the forum's 3 - * own PDS and updates themePolicy to reference those local URIs instead of 4 - * the canonical atbb.space records. 5 - * 6 - * Use this for self-hosted forums that want zero external theme dependencies. 7 - * Safe to re-run — putRecord is upsert. The operator can still customize 8 - * individual presets in the admin theme editor afterward. 9 - * 10 - * Usage: 11 - * pnpm --filter @atbb/appview bootstrap-local-presets 12 - * pnpm --filter @atbb/appview bootstrap-local-presets --dry-run 13 - * 14 - * Required env vars: 15 - * PDS_URL — Forum's PDS endpoint 16 - * FORUM_HANDLE — Forum service account handle 17 - * FORUM_PASSWORD — Forum service account password 18 - */ 19 - import { AtpAgent } from "@atproto/api"; 20 - import { readFileSync } from "fs"; 21 - import { fileURLToPath } from "url"; 22 - import { dirname, join } from "path"; 23 - 24 - const __dirname = dirname(fileURLToPath(import.meta.url)); 25 - const PRESET_DIR = join(__dirname, "../../web/src/styles/presets"); 26 - 27 - const PRESETS = [ 28 - { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 29 - { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 30 - { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 31 - { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 32 - { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 33 - ] as const; 34 - 35 - const isDryRun = process.argv.includes("--dry-run"); 36 - 37 - const pdsUrl = process.env.PDS_URL; 38 - const handle = process.env.FORUM_HANDLE; 39 - const password = process.env.FORUM_PASSWORD; 40 - 41 - if (!pdsUrl || !handle || !password) { 42 - console.error("Required environment variables: PDS_URL, FORUM_HANDLE, FORUM_PASSWORD"); 43 - process.exit(1); 44 - } 45 - 46 - async function run() { 47 - if (isDryRun) console.log("[dry-run] No records will be written.\n"); 48 - 49 - const agent = new AtpAgent({ service: pdsUrl! }); 50 - await agent.login({ identifier: handle!, password: password! }); 51 - 52 - const did = agent.session?.did; 53 - if (!did) throw new Error("Login succeeded but session has no DID"); 54 - 55 - console.log(`Authenticated as ${handle} (${did})`); 56 - console.log(`Writing ${PRESETS.length} preset records to local PDS (${pdsUrl})\n`); 57 - 58 - const now = new Date().toISOString(); 59 - const localUris: Array<{ rkey: string; uri: string }> = []; 60 - 61 - for (const preset of PRESETS) { 62 - const tokens = JSON.parse( 63 - readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 64 - ) as Record<string, string>; 65 - 66 - const uri = `at://${did}/space.atbb.forum.theme/${preset.rkey}`; 67 - localUris.push({ rkey: preset.rkey, uri }); 68 - 69 - if (isDryRun) { 70 - console.log(` ~ ${preset.name} — would write ${uri}`); 71 - continue; 72 - } 73 - 74 - await agent.com.atproto.repo.putRecord({ 75 - repo: did, 76 - collection: "space.atbb.forum.theme", 77 - rkey: preset.rkey, 78 - record: { 79 - $type: "space.atbb.forum.theme", 80 - name: preset.name, 81 - colorScheme: preset.colorScheme, 82 - tokens, 83 - createdAt: now, 84 - updatedAt: now, 85 - }, 86 - }); 87 - 88 - console.log(` ✓ ${preset.name} — written at ${uri}`); 89 - } 90 - 91 - // Build themePolicy with local URI-only refs (no cid = live refs pointing at this forum's PDS) 92 - const lightUri = localUris.find((t) => t.rkey === "neobrutal-light")!.uri; 93 - const darkUri = localUris.find((t) => t.rkey === "neobrutal-dark")!.uri; 94 - const available = localUris.map((t) => ({ uri: t.uri })); 95 - 96 - // Show existing themePolicy before overwriting so the operator can see what will change 97 - try { 98 - const existing = await agent.com.atproto.repo.getRecord({ 99 - repo: did, 100 - collection: "space.atbb.forum.themePolicy", 101 - rkey: "self", 102 - }); 103 - const rec = existing.data.value as Record<string, unknown>; 104 - const existingLight = (rec.defaultLightTheme as Record<string, string> | undefined)?.uri; 105 - const existingDark = (rec.defaultDarkTheme as Record<string, string> | undefined)?.uri; 106 - console.log("\nExisting themePolicy:"); 107 - console.log(` defaultLightTheme: ${existingLight ?? "(none)"}`); 108 - console.log(` defaultDarkTheme: ${existingDark ?? "(none)"}`); 109 - } catch { 110 - console.log("\nNo existing themePolicy found — will create."); 111 - } 112 - 113 - console.log("\nWriting themePolicy with local refs..."); 114 - 115 - if (isDryRun) { 116 - console.log(` ~ themePolicy — would write ${available.length} local refs`); 117 - console.log(` defaultLightTheme: ${lightUri}`); 118 - console.log(` defaultDarkTheme: ${darkUri}`); 119 - } else { 120 - await agent.com.atproto.repo.putRecord({ 121 - repo: did, 122 - collection: "space.atbb.forum.themePolicy", 123 - rkey: "self", 124 - record: { 125 - $type: "space.atbb.forum.themePolicy", 126 - availableThemes: available, 127 - defaultLightTheme: { uri: lightUri }, 128 - defaultDarkTheme: { uri: darkUri }, 129 - allowUserChoice: true, 130 - updatedAt: now, 131 - }, 132 - }); 133 - 134 - console.log(" ✓ themePolicy written"); 135 - console.log(` defaultLightTheme: ${lightUri}`); 136 - console.log(` defaultDarkTheme: ${darkUri}`); 137 - } 138 - 139 - console.log("\nDone. This forum now uses only local preset refs (no atbb.space dependency)."); 140 - console.log("You can still customize presets in the admin theme editor."); 141 - } 142 - 143 - run().catch((err) => { 144 - console.error("Failed:", err instanceof Error ? err.message : String(err)); 145 - process.exit(1); 146 - });
-147
apps/appview/scripts/publish-presets.ts
··· 1 - /** 2 - * Release pipeline script: publishes canonical built-in preset theme records 3 - * to the atbb.space PDS. Safe to re-run — uses putRecord (upsert semantics). 4 - * Skips presets whose token values are already up to date. 5 - * 6 - * Usage: 7 - * pnpm --filter @atbb/appview publish-presets 8 - * pnpm --filter @atbb/appview publish-presets --dry-run 9 - * 10 - * Required env vars: 11 - * PDS_URL — Forum PDS endpoint (e.g. https://pds.atbb.space) 12 - * FORUM_HANDLE — Forum service account handle 13 - * FORUM_PASSWORD — Forum service account password 14 - */ 15 - import { AtpAgent } from "@atproto/api"; 16 - import { readFileSync } from "fs"; 17 - import { fileURLToPath } from "url"; 18 - import { dirname, join } from "path"; 19 - 20 - const __dirname = dirname(fileURLToPath(import.meta.url)); 21 - const PRESET_DIR = join(__dirname, "../../web/src/styles/presets"); 22 - 23 - const PRESETS = [ 24 - { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 25 - { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 26 - { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 27 - { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 28 - { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 29 - ] as const; 30 - 31 - const isDryRun = process.argv.includes("--dry-run"); 32 - 33 - const pdsUrl = process.env.PDS_URL; 34 - const handle = process.env.FORUM_HANDLE; 35 - const password = process.env.FORUM_PASSWORD; 36 - 37 - if (!pdsUrl || !handle || !password) { 38 - console.error("Required environment variables: PDS_URL, FORUM_HANDLE, FORUM_PASSWORD"); 39 - process.exit(1); 40 - } 41 - 42 - /** Stable JSON string for comparison — sorted token keys, ignores ordering differences. */ 43 - function stableTokensJson(tokens: Record<string, string>): string { 44 - return JSON.stringify(Object.fromEntries(Object.entries(tokens).sort())); 45 - } 46 - 47 - /** True if the existing PDS record has the same content as the local preset. */ 48 - function isRecordCurrent( 49 - existing: Record<string, unknown>, 50 - preset: { name: string; colorScheme: string }, 51 - tokens: Record<string, string> 52 - ): boolean { 53 - return ( 54 - existing.name === preset.name && 55 - existing.colorScheme === preset.colorScheme && 56 - stableTokensJson(existing.tokens as Record<string, string>) === stableTokensJson(tokens) 57 - ); 58 - } 59 - 60 - async function run() { 61 - if (isDryRun) console.log("[dry-run] No records will be written.\n"); 62 - 63 - const agent = new AtpAgent({ service: pdsUrl! }); 64 - await agent.login({ identifier: handle!, password: password! }); 65 - 66 - const did = agent.session?.did; 67 - if (!did) throw new Error("Login succeeded but session has no DID"); 68 - 69 - console.log(`Authenticated as ${handle} (${did})`); 70 - console.log(`Publishing ${PRESETS.length} presets to ${pdsUrl}\n`); 71 - 72 - const now = new Date().toISOString(); 73 - let written = 0; 74 - let skipped = 0; 75 - 76 - for (const preset of PRESETS) { 77 - const tokens = JSON.parse( 78 - readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 79 - ) as Record<string, string>; 80 - 81 - // Fetch existing record to check for changes and preserve createdAt 82 - let existingCreatedAt: string | null = null; 83 - let alreadyCurrent = false; 84 - 85 - try { 86 - const res = await agent.com.atproto.repo.getRecord({ 87 - repo: did, 88 - collection: "space.atbb.forum.theme", 89 - rkey: preset.rkey, 90 - }); 91 - const existing = res.data.value as Record<string, unknown>; 92 - existingCreatedAt = (existing.createdAt as string) ?? null; 93 - 94 - if (isRecordCurrent(existing, preset, tokens)) { 95 - alreadyCurrent = true; 96 - } 97 - } catch (err: unknown) { 98 - // Only swallow 404 — record doesn't exist yet and will be created. 99 - // Re-throw anything else (network errors, auth failures, etc.). 100 - const status = (err as Record<string, unknown>).status; 101 - if (status !== 404) throw err; 102 - } 103 - 104 - if (alreadyCurrent) { 105 - console.log(` ✓ ${preset.name} — unchanged, skipping`); 106 - skipped++; 107 - continue; 108 - } 109 - 110 - if (isDryRun) { 111 - const action = existingCreatedAt ? "update" : "create"; 112 - console.log(` ~ ${preset.name} — would ${action}`); 113 - written++; 114 - continue; 115 - } 116 - 117 - const record: Record<string, unknown> = { 118 - $type: "space.atbb.forum.theme", 119 - name: preset.name, 120 - colorScheme: preset.colorScheme, 121 - tokens, 122 - createdAt: existingCreatedAt ?? now, 123 - }; 124 - // Only set updatedAt on updates, not initial creates 125 - if (existingCreatedAt) { 126 - record.updatedAt = now; 127 - } 128 - 129 - await agent.com.atproto.repo.putRecord({ 130 - repo: did, 131 - collection: "space.atbb.forum.theme", 132 - rkey: preset.rkey, 133 - record, 134 - }); 135 - 136 - const action = existingCreatedAt ? "updated" : "created"; 137 - console.log(` ✓ ${preset.name} — ${action}`); 138 - written++; 139 - } 140 - 141 - console.log(`\nDone. ${written} written, ${skipped} unchanged.`); 142 - } 143 - 144 - run().catch((err) => { 145 - console.error("Failed:", err instanceof Error ? err.message : String(err)); 146 - process.exit(1); 147 - });
+309
packages/cli/src/commands/theme.ts
··· 1 + import { defineCommand } from "citty"; 2 + import consola from "consola"; 3 + import { readFileSync } from "fs"; 4 + import { fileURLToPath } from "url"; 5 + import { dirname, join } from "path"; 6 + import { ForumAgent } from "@atbb/atproto"; 7 + import { loadCliConfig } from "../lib/config.js"; 8 + import { logger } from "../lib/logger.js"; 9 + 10 + const __dirname = dirname(fileURLToPath(import.meta.url)); 11 + const PRESET_DIR = join(__dirname, "../../../../apps/web/src/styles/presets"); 12 + 13 + const PRESETS = [ 14 + { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 15 + { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 16 + { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 17 + { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 18 + { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 19 + ] as const; 20 + 21 + /** Stable JSON string for comparison — sorted token keys, ignores ordering differences. */ 22 + function stableTokensJson(tokens: Record<string, string>): string { 23 + return JSON.stringify(Object.fromEntries(Object.entries(tokens).sort())); 24 + } 25 + 26 + /** True if the existing PDS record has the same content as the local preset. */ 27 + function isRecordCurrent( 28 + existing: Record<string, unknown>, 29 + preset: { name: string; colorScheme: string }, 30 + tokens: Record<string, string> 31 + ): boolean { 32 + return ( 33 + existing.name === preset.name && 34 + existing.colorScheme === preset.colorScheme && 35 + stableTokensJson(existing.tokens as Record<string, string>) === stableTokensJson(tokens) 36 + ); 37 + } 38 + 39 + /** 40 + * Authenticate using ForumAgent and return the raw agent + DID. 41 + * Theme commands only need PDS_URL, FORUM_HANDLE, FORUM_PASSWORD — 42 + * DATABASE_URL and FORUM_DID are not required. 43 + */ 44 + async function authenticate(config: ReturnType<typeof loadCliConfig>) { 45 + const themeEnvMissing = config.missing.filter( 46 + (v) => v !== "DATABASE_URL" && v !== "FORUM_DID" 47 + ); 48 + if (themeEnvMissing.length > 0) { 49 + consola.error("Missing required environment variables:"); 50 + for (const name of themeEnvMissing) consola.error(` - ${name}`); 51 + process.exit(1); 52 + } 53 + 54 + consola.start("Authenticating..."); 55 + const forumAgent = new ForumAgent( 56 + config.pdsUrl, config.forumHandle, config.forumPassword, logger 57 + ); 58 + 59 + try { 60 + await forumAgent.initialize(); 61 + } catch (error) { 62 + consola.error( 63 + `Failed to reach PDS (${config.pdsUrl}):`, 64 + error instanceof Error ? error.message : String(error) 65 + ); 66 + try { await forumAgent.shutdown(); } catch {} 67 + process.exit(1); 68 + } 69 + 70 + if (!forumAgent.isAuthenticated()) { 71 + const status = forumAgent.getStatus(); 72 + consola.error(`Authentication failed: ${status.error}`); 73 + await forumAgent.shutdown(); 74 + process.exit(1); 75 + } 76 + 77 + const agent = forumAgent.getAgent()!; 78 + const did = agent.session?.did; 79 + if (!did) { 80 + consola.error("Login succeeded but session has no DID"); 81 + await forumAgent.shutdown(); 82 + process.exit(1); 83 + } 84 + 85 + consola.success(`Authenticated as ${config.forumHandle} (${did})`); 86 + return { agent, did, forumAgent }; 87 + } 88 + 89 + // ── bootstrap-local ────────────────────────────────────────────────────────── 90 + 91 + const bootstrapLocalCommand = defineCommand({ 92 + meta: { 93 + name: "bootstrap-local", 94 + description: 95 + "Mirror built-in preset themes to your own PDS — zero external dependencies", 96 + }, 97 + args: { 98 + "dry-run": { 99 + type: "boolean", 100 + description: "Show what would be written without making any changes", 101 + default: false, 102 + }, 103 + }, 104 + async run({ args }) { 105 + const isDryRun = args["dry-run"]; 106 + consola.box("atBB — Bootstrap Local Presets" + (isDryRun ? " [dry-run]" : "")); 107 + 108 + const config = loadCliConfig(); 109 + const { agent, did, forumAgent } = await authenticate(config); 110 + 111 + consola.info(`Writing ${PRESETS.length} preset records to ${config.pdsUrl}`); 112 + if (isDryRun) consola.warn("Dry-run: no changes will be made."); 113 + consola.log(""); 114 + 115 + const now = new Date().toISOString(); 116 + const localUris: Array<{ rkey: string; uri: string }> = []; 117 + 118 + for (const preset of PRESETS) { 119 + const tokens = JSON.parse( 120 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 121 + ) as Record<string, string>; 122 + 123 + const uri = `at://${did}/space.atbb.forum.theme/${preset.rkey}`; 124 + localUris.push({ rkey: preset.rkey, uri }); 125 + 126 + if (isDryRun) { 127 + consola.info(` ~ ${preset.name} — would write ${uri}`); 128 + continue; 129 + } 130 + 131 + await agent.com.atproto.repo.putRecord({ 132 + repo: did, 133 + collection: "space.atbb.forum.theme", 134 + rkey: preset.rkey, 135 + record: { 136 + $type: "space.atbb.forum.theme", 137 + name: preset.name, 138 + colorScheme: preset.colorScheme, 139 + tokens, 140 + createdAt: now, 141 + updatedAt: now, 142 + }, 143 + }); 144 + consola.success(` ${preset.name}`); 145 + } 146 + 147 + const lightUri = localUris.find((t) => t.rkey === "neobrutal-light")!.uri; 148 + const darkUri = localUris.find((t) => t.rkey === "neobrutal-dark")!.uri; 149 + const available = localUris.map((t) => ({ uri: t.uri })); 150 + 151 + consola.log(""); 152 + 153 + // Show existing themePolicy before overwriting so operators can see what will change 154 + try { 155 + const existing = await agent.com.atproto.repo.getRecord({ 156 + repo: did, 157 + collection: "space.atbb.forum.themePolicy", 158 + rkey: "self", 159 + }); 160 + const rec = existing.data.value as Record<string, unknown>; 161 + const existingLight = (rec.defaultLightTheme as Record<string, string> | undefined)?.uri; 162 + const existingDark = (rec.defaultDarkTheme as Record<string, string> | undefined)?.uri; 163 + consola.info("Existing themePolicy:"); 164 + consola.info(` defaultLightTheme: ${existingLight ?? "(none)"}`); 165 + consola.info(` defaultDarkTheme: ${existingDark ?? "(none)"}`); 166 + } catch { 167 + consola.info("No existing themePolicy — will create."); 168 + } 169 + 170 + if (isDryRun) { 171 + consola.info(` ~ themePolicy — would write ${available.length} local refs`); 172 + consola.info(` defaultLightTheme: ${lightUri}`); 173 + consola.info(` defaultDarkTheme: ${darkUri}`); 174 + } else { 175 + await agent.com.atproto.repo.putRecord({ 176 + repo: did, 177 + collection: "space.atbb.forum.themePolicy", 178 + rkey: "self", 179 + record: { 180 + $type: "space.atbb.forum.themePolicy", 181 + availableThemes: available, 182 + defaultLightTheme: { uri: lightUri }, 183 + defaultDarkTheme: { uri: darkUri }, 184 + allowUserChoice: true, 185 + updatedAt: now, 186 + }, 187 + }); 188 + consola.success("themePolicy written"); 189 + consola.info(` defaultLightTheme: ${lightUri}`); 190 + consola.info(` defaultDarkTheme: ${darkUri}`); 191 + } 192 + 193 + await forumAgent.shutdown(); 194 + consola.log(""); 195 + consola.box( 196 + "Done — this forum now uses only local preset refs (no atbb.space dependency).\n" + 197 + "You can still customize presets in the admin theme editor." 198 + ); 199 + }, 200 + }); 201 + 202 + // ── publish-canonical ──────────────────────────────────────────────────────── 203 + 204 + const publishCanonicalCommand = defineCommand({ 205 + meta: { 206 + name: "publish-canonical", 207 + description: 208 + "[atbb.space only] Publish built-in preset themes to the canonical PDS. " + 209 + "Safe to re-run — uses upsert semantics, skips unchanged presets.", 210 + }, 211 + args: { 212 + "dry-run": { 213 + type: "boolean", 214 + description: "Show what would be written without making any changes", 215 + default: false, 216 + }, 217 + }, 218 + async run({ args }) { 219 + const isDryRun = args["dry-run"]; 220 + consola.box("atBB — Publish Canonical Presets" + (isDryRun ? " [dry-run]" : "")); 221 + 222 + const config = loadCliConfig(); 223 + const { agent, did, forumAgent } = await authenticate(config); 224 + 225 + consola.info(`Publishing ${PRESETS.length} presets to ${config.pdsUrl}`); 226 + if (isDryRun) consola.warn("Dry-run: no changes will be made."); 227 + consola.log(""); 228 + 229 + const now = new Date().toISOString(); 230 + let written = 0; 231 + let skipped = 0; 232 + 233 + for (const preset of PRESETS) { 234 + const tokens = JSON.parse( 235 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 236 + ) as Record<string, string>; 237 + 238 + // Fetch existing record to check for changes and preserve createdAt 239 + let existingCreatedAt: string | null = null; 240 + let alreadyCurrent = false; 241 + 242 + try { 243 + const res = await agent.com.atproto.repo.getRecord({ 244 + repo: did, 245 + collection: "space.atbb.forum.theme", 246 + rkey: preset.rkey, 247 + }); 248 + const existing = res.data.value as Record<string, unknown>; 249 + existingCreatedAt = (existing.createdAt as string) ?? null; 250 + if (isRecordCurrent(existing, preset, tokens)) alreadyCurrent = true; 251 + } catch (err: unknown) { 252 + // Only swallow 404 — record doesn't exist yet and will be created. 253 + // Re-throw anything else (network errors, auth failures, etc.). 254 + const status = (err as Record<string, unknown>).status; 255 + if (status !== 404) throw err; 256 + } 257 + 258 + if (alreadyCurrent) { 259 + consola.success(`${preset.name} — unchanged`); 260 + skipped++; 261 + continue; 262 + } 263 + 264 + const action = existingCreatedAt ? "updated" : "created"; 265 + 266 + if (isDryRun) { 267 + consola.info(` ~ ${preset.name} — would ${action}`); 268 + written++; 269 + continue; 270 + } 271 + 272 + const record: Record<string, unknown> = { 273 + $type: "space.atbb.forum.theme", 274 + name: preset.name, 275 + colorScheme: preset.colorScheme, 276 + tokens, 277 + createdAt: existingCreatedAt ?? now, 278 + }; 279 + // Only set updatedAt on updates, not initial creates 280 + if (existingCreatedAt) record.updatedAt = now; 281 + 282 + await agent.com.atproto.repo.putRecord({ 283 + repo: did, 284 + collection: "space.atbb.forum.theme", 285 + rkey: preset.rkey, 286 + record, 287 + }); 288 + consola.success(`${preset.name} — ${action}`); 289 + written++; 290 + } 291 + 292 + await forumAgent.shutdown(); 293 + consola.log(""); 294 + consola.info(`Done. ${written} written, ${skipped} unchanged.`); 295 + }, 296 + }); 297 + 298 + // ── theme command group ────────────────────────────────────────────────────── 299 + 300 + export const themeCommand = defineCommand({ 301 + meta: { 302 + name: "theme", 303 + description: "Manage forum themes and preset publishing", 304 + }, 305 + subCommands: { 306 + "bootstrap-local": bootstrapLocalCommand, 307 + "publish-canonical": publishCanonicalCommand, 308 + }, 309 + });
+2
packages/cli/src/index.ts
··· 3 3 import { initCommand } from "./commands/init.js"; 4 4 import { categoryCommand } from "./commands/category.js"; 5 5 import { boardCommand } from "./commands/board.js"; 6 + import { themeCommand } from "./commands/theme.js"; 6 7 7 8 const main = defineCommand({ 8 9 meta: { ··· 14 15 init: initCommand, 15 16 category: categoryCommand, 16 17 board: boardCommand, 18 + theme: themeCommand, 17 19 }, 18 20 }); 19 21