From 0bb6519321dfc10d5cd2bb0204c377dd773b873e Mon Sep 17 00:00:00 2001 From: Tim Trautmann Date: Fri, 29 May 2026 10:58:21 +0000 Subject: [PATCH] CLI: set-icon + set-theme commands; default theme is now "skip" set-icon replaces the publication icon and re-bootstraps the site.standard.publication record. Validates dimensions (≥256×256) and byte size (<1MB) before saving — same checks the setup wizard runs. set-theme [preset] changes the basicTheme palette. With no arg it prompts interactively; with a preset name (light_orange, light_blue, dark_orange, skip) it switches directly. Re-bootstraps the publication record at the end. Shared theme presets + hex regex moved to src/lib/themes.ts so setup and set-theme reference one source of truth. Default theme during setup (and in set-theme's interactive picker) is now "skip" — drops basicTheme from the record entirely so readers use their own defaults. Light + brand-orange becomes an opt-in for operators who actually want their PDS record to advertise specific colors. --- README.md | 27 +++++++++++++++++++-------- src/cli.ts | 24 ++++++++++++++++++++++-- src/commands/set-icon.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/commands/set-theme.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/commands/setup.ts | 26 ++++++-------------------- src/lib/themes.ts | 20 ++++++++++++++++++++ 6 file(s) changed, 205 insertion(s)(+), 30 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ A self-hosted bridge that mirrors a Ghost blog into [Standard.site](https://standard.site) records on an AT Protocol PDS — your own, or Bluesky's. Once configured, every post you publish in Ghost becomes a `site.standard.document` record at `at://…`, and your blog domain is verified as a `site.standard.publication`. New posts sync automatically via Ghost webhooks; the `` tag gets dropped into each post's `` without you touching the Ghost editor. -Self-contained: Node + Hono + SQLite, behind Caddy for TLS. One Docker image, one named volume. Configuration is a CLI, not a web UI — fewer moving parts, easier to script, no admin auth to manage. +Self-contained: one Docker container, one named volume, and Caddy in front for TLS. Configuration is a CLI you run inside the container — no admin web UI to log into, no separate dashboard process to keep alive. -> **Status: v0.1 in active development.** Scaffolding done, AT proto + Ghost helpers porting from a working prototype. See the `// TODO` markers in `src/lib/atproto.ts` and `src/lib/ghost.ts`. +> **Status: v0.1 in active development.** Scaffolding done, sync pipeline ported from a working prototype, end-to-end not yet tested against a fresh Ghost install. ## What you need @@ -27,15 +27,16 @@ The wizard walks you through: 1. **PDS connection** — service URL, handle, app password (validated by login) -2. **Ghost connection** — URL + Admin API key (validated by a posts.browse call) +2. **Ghost connection** — URL + Admin API key (validated by a `posts.browse` call), plus the public URL you'll point Ghost's webhooks at 3. **Publication metadata** — name, description, theme palette (presets or custom hex), icon path (`/data/assets/icon.png` by default, mounted from the host's `./assets/`) -4. **Bootstrap** — creates the `site.standard.publication` record, auto-generates the Ghost webhook secret, and prints the two pieces of glue you wire up by hand: +4. **Bootstrap** — creates the `site.standard.publication` record, auto-generates the Ghost webhook secret, and prints the three pieces of glue you wire up by hand: - The `/.well-known/site.standard.publication` content for your Ghost reverse-proxy - The four Ghost webhooks to add to your custom integration (URL + secret prefilled) + - The `` to drop into your theme's homepage `` ## CLI -All admin runs through the same CLI inside the container: +All admin runs through the CLI inside the container: ```bash docker compose exec app ghoststandard setup # first-run wizard @@ -45,14 +46,14 @@ docker compose exec app ghoststandard backfill # re-sync every published post ``` -The webhook receiver runs continuously as the container's main process; the CLI shares the same SQLite, so changes are visible immediately without restart. +The webhook receiver runs continuously as the container's main process; the CLI shares the same SQLite, so changes take effect immediately without restart. ## Architecture ``` ┌───────────┐ webhook ┌──────────────────┐ atproto API ┌──────────────┐ │ Ghost │ ────────────> │ ghoststandard │ ──────────────> │ AT proto │ - │ (blog) │ │ (Hono+SQLite) │ │ PDS │ + │ (blog) │ │ (container) │ │ PDS │ └───────────┘ └────────┬─────────┘ └──────────────┘ │ ┌──────────────────┐ │ stores ──────> │ /data/...sqlite │ @@ -60,7 +61,6 @@ ▼ └──────────────────┘ ┌──────────┐ │ CLI │ ← docker compose exec app ghoststandard … - │ (Node) │ └──────────┘ ``` @@ -81,6 +81,17 @@ npm run typecheck npm run build # → dist/ ``` + +### Stack (for contributors) + +- TypeScript (strict, ESM) +- [Hono](https://hono.dev) — webhook receiver +- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) — embedded persistence +- [@atproto/api](https://github.com/bluesky-social/atproto/tree/main/packages/api) — PDS client +- [@tryghost/admin-api](https://github.com/TryGhost/SDK/tree/main/packages/admin-api) — Ghost Admin client +- [@inquirer/prompts](https://github.com/SBoudrias/Inquirer.js) + [picocolors](https://github.com/alexeyraspopov/picocolors) + [ora](https://github.com/sindresorhus/ora) — CLI polish +- [sharp](https://sharp.pixelplumbing.com/) — cover-image resize +- [Caddy](https://caddyserver.com/) — reverse proxy + automatic TLS ## License diff --git a/src/cli.ts b/src/cli.ts --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,8 @@ import { runUnsync } from './commands/unsync.js'; import { runBackfill } from './commands/backfill.js'; import { runStatus } from './commands/status.js'; +import { runSetIcon } from './commands/set-icon.js'; +import { runSetTheme } from './commands/set-theme.js'; function printHelp(): void { const cmd = pc.cyan; @@ -28,10 +30,14 @@ ${pc.bold('Commands:')} ${cmd('setup')} First-run interactive setup - ${cmd('sync')} ${arg('')} Manually sync one post - ${cmd('unsync')} ${arg('')} Delete one PDS record + mapping + ${cmd('sync')} ${arg('')} Manually sync one post + ${cmd('unsync')} ${arg('')} Delete one PDS record + mapping ${cmd('backfill')} Re-sync every published post ${cmd('status')} Show health, counts, last sync + ${cmd('set-icon')} ${arg('')} Replace the publication icon + ${cmd('set-theme')} ${arg('[preset]')} Change the publication theme palette + ${arg('(no arg → interactive; or pass one of:')} + ${arg(' light_orange, light_blue, dark_orange, skip)')} ${pc.dim('Run inside the container:')} ${pc.dim('docker compose exec app ghoststandard ')} @@ -79,6 +85,20 @@ case 'status': await runStatus(); + return; + + case 'set-icon': { + const path = rest[0]; + if (!path) { + console.error(pc.red('error: ') + 'set-icon requires a file path'); + process.exit(2); + } + await runSetIcon(path); + return; + } + + case 'set-theme': + await runSetTheme(rest[0]); return; default: diff --git a/src/commands/set-icon.ts b/src/commands/set-icon.ts new file mode 100644 --- /dev/null +++ b/src/commands/set-icon.ts @@ -0,0 +1,46 @@ +// `ghoststandard set-icon ` — replace the publication's icon without +// re-running the full setup wizard. Path is interpreted inside the container +// (typically /data/assets/ with the host's ./assets/ bind mount). + +import { promises as fs } from 'node:fs'; +import ora from 'ora'; +import pc from 'picocolors'; + +import { getConfig, setConfig } from '../lib/db.js'; +import { upsertPublication } from '../lib/atproto.js'; + +async function assertValidIcon(path: string): Promise { + try { + await fs.access(path); + } catch { + throw new Error(`File not found: ${path}`); + } + const stat = await fs.stat(path); + if (stat.size > 1_000_000) { + throw new Error(`Icon is ${(stat.size / 1024).toFixed(0)} KB — must be under 1 MB`); + } + const sharp = (await import('sharp')).default; + const meta = await sharp(path).metadata(); + if (!meta.width || !meta.height) throw new Error('Could not read image dimensions'); + if (meta.width < 256 || meta.height < 256) { + throw new Error(`Icon is ${meta.width}×${meta.height} — must be at least 256×256`); + } +} + +export async function runSetIcon(path: string): Promise { + if (getConfig('setup_complete') !== 'true') { + throw new Error('Run `ghoststandard setup` first'); + } + await assertValidIcon(path); + + setConfig('publication_icon_path', path); + + const spinner = ora('Pushing new icon to PDS…').start(); + try { + const result = await upsertPublication(); + spinner.succeed(`Publication record updated — ${pc.dim(result.uri)}`); + } catch (err) { + spinner.fail('Publication update failed'); + throw err; + } +} diff --git a/src/commands/set-theme.ts b/src/commands/set-theme.ts new file mode 100644 --- /dev/null +++ b/src/commands/set-theme.ts @@ -0,0 +1,92 @@ +// `ghoststandard set-theme [preset]` — change the publication's basicTheme +// palette without re-running the full setup wizard. Three modes: +// +// set-theme → interactive (preset/custom/skip) +// set-theme light_blue → switch directly to a named preset +// set-theme skip → drop basicTheme from the record entirely + +import { input, select } from '@inquirer/prompts'; +import ora from 'ora'; +import pc from 'picocolors'; + +import { getConfig, setConfig } from '../lib/db.js'; +import { upsertPublication } from '../lib/atproto.js'; +import { HEX_RE, THEME_PRESETS, type ThemeColors } from '../lib/themes.js'; + +async function promptCustomTheme(): Promise { + console.log(pc.dim('\n Enter four hex codes (#RRGGBB). White-on-orange defaults in []:\n')); + return { + background: await input({ + message: 'Background', default: '#FAFAFA', + validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', + }), + foreground: await input({ + message: 'Foreground (text)', default: '#231F20', + validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', + }), + accent: await input({ + message: 'Accent', default: '#FF5C1F', + validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', + }), + accentForeground: await input({ + message: 'Accent text colour (on accent buttons etc.)', default: '#FFFFFF', + validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', + }), + }; +} + +async function pickInteractively(): Promise { + const preset = await select({ + message: 'Theme palette', + default: 'skip', + choices: [ + { name: 'Skip — drop basicTheme, readers use defaults (recommended)', value: 'skip' }, + { name: 'Light + warm orange accent', value: 'light_orange' }, + { name: 'Light + Bluesky blue accent', value: 'light_blue' }, + { name: 'Dark + warm orange accent', value: 'dark_orange' }, + { name: 'Custom — enter your own four hex codes', value: 'custom' }, + ], + }); + if (preset === 'skip') return null; + if (preset === 'custom') return promptCustomTheme(); + return THEME_PRESETS[preset] ?? null; +} + +function fromArg(arg: string): ThemeColors | null | undefined { + if (arg === 'skip') return null; + return THEME_PRESETS[arg]; +} + +export async function runSetTheme(arg?: string): Promise { + if (getConfig('setup_complete') !== 'true') { + throw new Error('Run `ghoststandard setup` first'); + } + + let theme: ThemeColors | null; + if (arg) { + const resolved = fromArg(arg); + if (resolved === undefined) { + const known = ['skip', ...Object.keys(THEME_PRESETS)].join(', '); + throw new Error(`Unknown preset \`${arg}\` — known: ${known}`); + } + theme = resolved; + } else { + theme = await pickInteractively(); + } + + setConfig('publication_theme', theme ? JSON.stringify(theme) : ''); + + const spinner = ora('Pushing new theme to PDS…').start(); + try { + const result = await upsertPublication(); + spinner.succeed(`Publication record updated — ${pc.dim(result.uri)}`); + if (theme) { + console.log(` accent: ${pc.bold(theme.accent)}`); + } else { + console.log(pc.dim(' basicTheme cleared')); + } + } catch (err) { + spinner.fail('Publication update failed'); + throw err; + } +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -19,28 +19,13 @@ import { setConfig } from '../lib/db.js'; import { upsertPublication } from '../lib/atproto.js'; +import { HEX_RE, THEME_PRESETS, type ThemeColors } from '../lib/themes.js'; const banner = ` ${pc.bold(pc.cyan(' ghoststandard setup'))} ${pc.dim(' Bridges your Ghost blog to AT Protocol via the site.standard.* lexicons.')} ${pc.dim(' Ctrl+C cancels — nothing is saved until the final confirmation.')} `; - -// site.standard.theme.basic is four RGB color refs. Presets pre-fill them. -type ThemeColors = { - background: string; - foreground: string; - accent: string; - accentForeground: string; -}; - -const THEME_PRESETS: Record = { - light_orange: { background: '#FAFAFA', foreground: '#231F20', accent: '#FF5C1F', accentForeground: '#FFFFFF' }, - light_blue: { background: '#FAFAFA', foreground: '#1A1A1A', accent: '#1083FE', accentForeground: '#FFFFFF' }, - dark_orange: { background: '#1A1A1A', foreground: '#E6E6E6', accent: '#FF5C1F', accentForeground: '#FFFFFF' }, -}; - -const HEX_RE = /^#[0-9a-fA-F]{6}$/; async function promptCustomTheme(): Promise { console.log(pc.dim('\n Enter four hex codes (e.g. #FAFAFA). White-on-orange defaults in []:\n')); @@ -146,12 +131,13 @@ const preset = (await select({ message: 'Theme palette (drives the basicTheme on your publication record)', + default: 'skip', choices: [ - { name: 'Light + warm orange accent (recommended)', value: 'light_orange' }, - { name: 'Light + Bluesky blue accent', value: 'light_blue' }, - { name: 'Dark + warm orange accent', value: 'dark_orange' }, + { name: 'Skip — no basicTheme, readers use defaults (recommended)', value: 'skip' }, + { name: 'Light + warm orange accent', value: 'light_orange' }, + { name: 'Light + Bluesky blue accent', value: 'light_blue' }, + { name: 'Dark + warm orange accent', value: 'dark_orange' }, { name: 'Custom — enter your own four hex codes', value: 'custom' }, - { name: 'Skip — no basicTheme, readers use defaults', value: 'skip' }, ], })) as keyof typeof THEME_PRESETS | 'custom' | 'skip'; diff --git a/src/lib/themes.ts b/src/lib/themes.ts new file mode 100644 --- /dev/null +++ b/src/lib/themes.ts @@ -0,0 +1,20 @@ +// Shared theme palette presets — used by `ghoststandard setup` and +// `ghoststandard set-theme`. +// +// Each preset is the four hex values that get expanded into RGB triples on the +// site.standard.theme.basic record (see lib/atproto.ts buildBasicTheme). + +export type ThemeColors = { + background: string; + foreground: string; + accent: string; + accentForeground: string; +}; + +export const THEME_PRESETS: Record = { + light_orange: { background: '#FAFAFA', foreground: '#231F20', accent: '#FF5C1F', accentForeground: '#FFFFFF' }, + light_blue: { background: '#FAFAFA', foreground: '#1A1A1A', accent: '#1083FE', accentForeground: '#FFFFFF' }, + dark_orange: { background: '#1A1A1A', foreground: '#E6E6E6', accent: '#FF5C1F', accentForeground: '#FFFFFF' }, +}; + +export const HEX_RE = /^#[0-9a-fA-F]{6}$/; -- tangled.sh