diff --git a/src/cli.ts b/src/cli.ts index 8d78ae5..61e1bce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,8 +36,7 @@ ${pc.bold('Commands:')} ${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)')} + ${arg('(no arg → interactive; or pass: skip, light, dark)')} ${pc.dim('Run inside the container:')} ${pc.dim('docker compose exec app gs ')} diff --git a/src/commands/set-icon.ts b/src/commands/set-icon.ts index 40472cf..536b741 100644 --- a/src/commands/set-icon.ts +++ b/src/commands/set-icon.ts @@ -1,6 +1,9 @@ -// `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). +// `gs 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). +// +// Same dimension floor as setup (≥60×60), same warn-not-block behavior when +// below Standard.site's ≥256×256 recommendation. import { promises as fs } from 'node:fs'; import ora from 'ora'; @@ -9,29 +12,47 @@ import pc from 'picocolors'; import { getConfig, setConfig } from '../lib/db.js'; import { upsertPublication } from '../lib/atproto.js'; -async function assertValidIcon(path: string): Promise { +const ICON_RECOMMENDED_DIMENSION = 256; +const ICON_MINIMUM_DIMENSION = 60; +const ICON_MAX_BYTES = 1_000_000; + +async function readDimensions(path: string): Promise<{ width: number; height: number }> { + 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'); + return { width: meta.width, height: meta.height }; +} + +async function assertValidIcon(path: string): Promise<{ width: number; height: number }> { 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) { + if (stat.size > ICON_MAX_BYTES) { 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`); + const dims = await readDimensions(path); + if (dims.width < ICON_MINIMUM_DIMENSION || dims.height < ICON_MINIMUM_DIMENSION) { + throw new Error( + `Icon is ${dims.width}×${dims.height} — must be at least ${ICON_MINIMUM_DIMENSION}×${ICON_MINIMUM_DIMENSION}`, + ); } + return dims; } export async function runSetIcon(path: string): Promise { if (getConfig('setup_complete') !== 'true') { - throw new Error('Run `ghoststandard setup` first'); + throw new Error('Run `gs setup` first'); + } + const dims = await assertValidIcon(path); + if (dims.width < ICON_RECOMMENDED_DIMENSION || dims.height < ICON_RECOMMENDED_DIMENSION) { + console.log( + pc.yellow(`⚠ ${dims.width}×${dims.height} is below Standard.site's recommended ≥256×256.`) + + pc.dim(' Readers may upscale it.'), + ); } - await assertValidIcon(path); setConfig('publication_icon_path', path); diff --git a/src/commands/set-theme.ts b/src/commands/set-theme.ts index 469d039..d21941c 100644 --- a/src/commands/set-theme.ts +++ b/src/commands/set-theme.ts @@ -1,9 +1,10 @@ -// `ghoststandard set-theme [preset]` — change the publication's basicTheme -// palette without re-running the full setup wizard. Three modes: +// `gs 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 +// gs set-theme → interactive (skip/light/dark/custom) +// gs set-theme light → light surface + your Ghost accent (or fallback) +// gs set-theme dark → dark surface + your Ghost accent (or fallback) +// gs set-theme skip → drop basicTheme from the record entirely import { input, select } from '@inquirer/prompts'; import ora from 'ora'; @@ -11,10 +12,17 @@ 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'; +import { fetchGhostSettings } from '../lib/ghost.js'; +import { + FALLBACK_ACCENT, + HEX_RE, + makeDarkTheme, + makeLightTheme, + 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')); +async function promptCustomTheme(accentDefault: string): Promise { + console.log(pc.dim('\n Enter four hex codes (#RRGGBB). Defaults shown in []:\n')); return { background: await input({ message: 'Background', default: '#FAFAFA', @@ -25,53 +33,64 @@ async function promptCustomTheme(): Promise { validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', }), accent: await input({ - message: 'Accent', default: '#FF5C1F', + message: 'Accent', default: accentDefault, validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', }), accentForeground: await input({ - message: 'Accent text colour (on accent buttons etc.)', default: '#FFFFFF', + message: 'Accent text colour', default: '#FFFFFF', validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', }), }; } -async function pickInteractively(): Promise { +async function pickInteractively(accentDefault: string, accentNote: string): 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' }, + { name: `Light + your accent ${accentNote}`, value: 'light' }, + { name: `Dark + your accent ${accentNote}`, value: 'dark' }, + { 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]; + if (preset === 'light') return makeLightTheme(accentDefault); + if (preset === 'dark') return makeDarkTheme(accentDefault); + return promptCustomTheme(accentDefault); } export async function runSetTheme(arg?: string): Promise { if (getConfig('setup_complete') !== 'true') { - throw new Error('Run `ghoststandard setup` first'); + throw new Error('Run `gs setup` first'); } + // Pull the current accent from Ghost so "light" / "dark" pick the right + // color without the operator typing it twice. + let accent = FALLBACK_ACCENT; + let accentSource = `${FALLBACK_ACCENT}, fallback`; + try { + const settings = await fetchGhostSettings(); + if (settings.accent_color) { + accent = settings.accent_color; + accentSource = `${settings.accent_color}, from Ghost`; + } + } catch { + // Ghost unreachable — fall back. Not fatal for changing the theme. + } + const accentNote = pc.dim(`(${accentSource})`); + 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}`); + switch (arg) { + case 'skip': theme = null; break; + case 'light': theme = makeLightTheme(accent); break; + case 'dark': theme = makeDarkTheme(accent); break; + default: + throw new Error(`Unknown preset \`${arg}\` — known: skip, light, dark`); } - theme = resolved; } else { - theme = await pickInteractively(); + theme = await pickInteractively(accent, accentNote); } setConfig('publication_theme', theme ? JSON.stringify(theme) : ''); @@ -80,11 +99,8 @@ export async function runSetTheme(arg?: string): Promise { 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')); - } + 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 index 22eff69..4efad19 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,15 +1,14 @@ // First-run setup wizard. // -// Walks the operator through PDS + Ghost credentials, publication metadata, -// theme choice (preset / custom hex / skip), and an optional 512×512 icon. -// Validates PDS login and Ghost API connectivity before saving anything; if -// either fails the operator can abort and re-run without re-typing. +// Two halves: collect credentials and validate them as we go (so the operator +// can fix a typo before answering eighteen more prompts), then collect +// publication metadata with Ghost's existing settings (title, description, +// accent color, icon) pre-filled as defaults so the operator mostly hits +// Enter through that section. // -// At the end (once lib/atproto upsertPublication is ported), prints: -// 1. The /.well-known/site.standard.publication file content -// 2. The Ghost webhook URL + auto-generated secret -// 3. A reminder about the on the -// blog index page +// On confirm: persist config, bootstrap the site.standard.publication record +// on the PDS, and print the three pieces of glue the operator wires up by hand +// (well-known file content, four Ghost webhooks, publication-level link rel). import { promises as fs } from 'node:fs'; import { randomBytes } from 'node:crypto'; @@ -19,7 +18,14 @@ import pc from 'picocolors'; import { setConfig } from '../lib/db.js'; import { upsertPublication } from '../lib/atproto.js'; -import { HEX_RE, THEME_PRESETS, type ThemeColors } from '../lib/themes.js'; +import { fetchGhostIcon, fetchGhostSettings, type GhostSettings } from '../lib/ghost.js'; +import { + FALLBACK_ACCENT, + HEX_RE, + makeDarkTheme, + makeLightTheme, + type ThemeColors, +} from '../lib/themes.js'; const banner = ` ${pc.bold(pc.cyan(' ghoststandard setup'))} @@ -27,45 +33,52 @@ ${pc.dim(' Bridges your Ghost blog to AT Protocol via the site.standard.* lexic ${pc.dim(' Ctrl+C cancels — nothing is saved until the final confirmation.')} `; -async function promptCustomTheme(): Promise { - console.log(pc.dim('\n Enter four hex codes (e.g. #FAFAFA). White-on-orange defaults in []:\n')); - const background = await input({ - message: 'Background', default: '#FAFAFA', - validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', - }); - const foreground = await input({ - message: 'Foreground (text)', default: '#231F20', - validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', - }); - const accent = await input({ - message: 'Accent', default: '#FF5C1F', - validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', - }); - const accentForeground = await input({ - message: 'Accent text colour (on accent buttons etc.)', default: '#FFFFFF', - validate: (v) => HEX_RE.test(v) || 'Expected #RRGGBB', - }); - return { background, foreground, accent, accentForeground }; +// Standard.site recommends ≥256×256, Ghost requires ≥60×60. We accept anything +// above Ghost's floor but warn loudly between 60 and 255 — operators with a +// small Ghost icon can still ship something rather than nothing. +const ICON_RECOMMENDED_DIMENSION = 256; +const ICON_MINIMUM_DIMENSION = 60; +const ICON_MAX_BYTES = 1_000_000; +const ICON_STORE_PATH = '/data/icon.png'; + +async function promptCustomTheme(accentDefault: string): Promise { + console.log(pc.dim('\n Enter four hex codes (#RRGGBB). Defaults shown 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: accentDefault, + 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', + }), + }; } -// Reads the icon, validates dimensions (≥256×256 per the lexicon) and byte -// size (<1MB per the blob convention), returns the absolute path on success. -async function validateIconPath(path: string): Promise { +async function validateLocalIconPath(path: string): Promise { try { await fs.access(path); } catch { return `File not found: ${path}`; } const stat = await fs.stat(path); - if (stat.size > 1_000_000) { + if (stat.size > ICON_MAX_BYTES) { return `Icon is ${(stat.size / 1024).toFixed(0)} KB — must be under 1 MB`; } try { const sharp = (await import('sharp')).default; const meta = await sharp(path).metadata(); if (!meta.width || !meta.height) return 'Could not read image dimensions'; - if (meta.width < 256 || meta.height < 256) { - return `Icon is ${meta.width}×${meta.height} — must be at least 256×256`; + if (meta.width < ICON_MINIMUM_DIMENSION || meta.height < ICON_MINIMUM_DIMENSION) { + return `Icon is ${meta.width}×${meta.height} — must be at least ${ICON_MINIMUM_DIMENSION}×${ICON_MINIMUM_DIMENSION}`; } return true; } catch (err) { @@ -73,10 +86,32 @@ async function validateIconPath(path: string): Promise { } } +async function localIconDimensions(path: string): Promise<{ width: number; height: number } | null> { + try { + const sharp = (await import('sharp')).default; + const meta = await sharp(path).metadata(); + if (!meta.width || !meta.height) return null; + return { width: meta.width, height: meta.height }; + } catch { + return null; + } +} + +function warnIfBelowRecommended(width: number, height: number): void { + if (width < ICON_RECOMMENDED_DIMENSION || height < ICON_RECOMMENDED_DIMENSION) { + console.log( + ' ' + pc.yellow(`⚠ ${width}×${height} is below Standard.site's recommended ≥256×256.`) + + pc.dim(' Readers may upscale it.'), + ); + } +} + export async function runSetup(): Promise { console.log(banner); - // ------------- 1. PDS ------------- + // ─────────────────────────────────────────────────────────────────────── + // 1. PDS connection + validation + // ─────────────────────────────────────────────────────────────────────── console.log(pc.bold('\n1. AT Protocol PDS connection')); console.log(pc.dim(' Use https://bsky.social for a Bluesky account, or your own PDS URL.')); @@ -95,7 +130,20 @@ export async function runSetup(): Promise { validate: (v) => v.length >= 8 || 'Too short — generate one in your PDS settings', }); - // ------------- 2. Ghost ------------- + const v1 = ora('Logging into PDS…').start(); + try { + const { AtpAgent } = await import('@atproto/api'); + const agent = new AtpAgent({ service: atproto_service }); + await agent.login({ identifier: atproto_handle, password: atproto_app_password }); + v1.succeed(`PDS login OK (${pc.dim(agent.session?.did ?? '?')})`); + } catch (err) { + v1.fail('PDS login failed'); + throw err; + } + + // ─────────────────────────────────────────────────────────────────────── + // 2. Ghost connection + validation + settings fetch + // ─────────────────────────────────────────────────────────────────────── console.log(pc.bold('\n2. Ghost blog')); console.log(pc.dim(' The Admin API key comes from Ghost Admin → Settings → Integrations →')); console.log(pc.dim(' Add custom integration. Format: key_id:hex_secret')); @@ -110,6 +158,20 @@ export async function runSetup(): Promise { validate: (v) => v.includes(':') || 'Expected key_id:hex_secret format', }); + // Write what we have so far so fetchGhostSettings (which reads config) works. + setConfig('ghost_url', ghost_url); + setConfig('ghost_admin_api_key', ghost_admin_api_key); + + let settings: GhostSettings; + const v2 = ora('Pinging Ghost Admin API + reading site settings…').start(); + try { + settings = await fetchGhostSettings(); + v2.succeed(`Ghost OK — title ${pc.dim('"' + (settings.title ?? '(unset)') + '"')}`); + } catch (err) { + v2.fail('Ghost Admin API call failed'); + throw err; + } + console.log(pc.dim('\n Ghost will call this bridge over HTTPS to deliver webhooks.')); const bridge_url = await input({ message: 'Public URL of this bridge', @@ -117,102 +179,122 @@ export async function runSetup(): Promise { validate: (v) => /^https?:\/\//.test(v) || 'Must start with http:// or https://', }); - // ------------- 3. Publication metadata ------------- + // ─────────────────────────────────────────────────────────────────────── + // 3. Publication metadata — pre-filled from Ghost where available + // ─────────────────────────────────────────────────────────────────────── console.log(pc.bold('\n3. Publication metadata')); + if (settings.title || settings.description || settings.accent_color) { + console.log(pc.dim(' Defaults below come from your Ghost site settings.')); + } const publication_name = await input({ - message: 'Publication name (e.g. "Your Name")', + message: 'Publication name', + default: settings.title ?? '', validate: (v) => v.length > 0 || 'Required', }); const publication_description = await input({ message: 'Description (optional, shown on Standard.site cards)', - default: '', + default: settings.description ?? '', }); + // ─────────────────────────────────────────────────────────────────────── + // 4. Theme palette — accent comes from Ghost when present + // ─────────────────────────────────────────────────────────────────────── + const accentDefault = settings.accent_color ?? FALLBACK_ACCENT; + const accentNote = settings.accent_color + ? pc.dim(`(${settings.accent_color}, from Ghost)`) + : pc.dim(`(${FALLBACK_ACCENT}, fallback — Ghost has no accent_color set)`); + const preset = (await select({ message: 'Theme palette (drives the basicTheme on your publication record)', default: 'skip', choices: [ { 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: `Light + your accent ${accentNote}`, value: 'light' }, + { name: `Dark + your accent ${accentNote}`, value: 'dark' }, + { name: 'Custom — enter your own four hex codes', value: 'custom' }, ], - })) as keyof typeof THEME_PRESETS | 'custom' | 'skip'; + })) as 'skip' | 'light' | 'dark' | 'custom'; let theme: ThemeColors | null = null; - if (preset === 'custom') { - theme = await promptCustomTheme(); - } else if (preset !== 'skip') { - theme = THEME_PRESETS[preset] ?? null; + if (preset === 'light') theme = makeLightTheme(accentDefault); + else if (preset === 'dark') theme = makeDarkTheme(accentDefault); + else if (preset === 'custom') theme = await promptCustomTheme(accentDefault); + + // ─────────────────────────────────────────────────────────────────────── + // 5. Icon — Ghost's existing icon is usually the right answer + // ─────────────────────────────────────────────────────────────────────── + console.log(pc.bold('\n5. Publication icon (optional)')); + console.log(pc.dim(' A square image (recommended ≥256×256, max 1 MB).')); + + const probe = ora('Checking Ghost for an existing icon…').start(); + let ghostIcon: Awaited> = null; + try { + ghostIcon = await fetchGhostIcon(); + if (ghostIcon) { + const small = ghostIcon.width < ICON_RECOMMENDED_DIMENSION || ghostIcon.height < ICON_RECOMMENDED_DIMENSION; + probe.succeed( + `Ghost icon: ${pc.bold(`${ghostIcon.width}×${ghostIcon.height}`)} ${pc.dim(ghostIcon.mimeType)}` + + (small ? ' ' + pc.yellow('⚠ below Standard.site\'s ≥256×256 recommendation') : ''), + ); + } else { + probe.info('Ghost has no publication icon set.'); + } + } catch (err) { + probe.warn(`Could not check Ghost: ${(err as Error).message}`); } - // ------------- 4. Icon (optional) ------------- - console.log(pc.bold('\n4. Publication icon (optional)')); - console.log(pc.dim(' A square PNG (≥256×256, <1MB) shown on Standard.site cards. Drop it into')); - console.log(pc.dim(' ./assets/ on the host — it appears at /data/assets/ inside the container.')); - console.log(pc.dim(' You can skip this and add one later with `ghoststandard set-icon`.')); + const iconChoices: { name: string; value: 'ghost' | 'file' | 'skip' }[] = []; + if (ghostIcon) iconChoices.push({ name: `Use Ghost's icon (${ghostIcon.width}×${ghostIcon.height})`, value: 'ghost' }); + iconChoices.push({ name: 'Provide a local file path', value: 'file' }); + iconChoices.push({ name: 'Skip — set later with `gs set-icon`', value: 'skip' }); + + const iconSource = await select({ + message: 'Icon source', + default: ghostIcon ? 'ghost' : 'skip', + choices: iconChoices, + }); - const want_icon = await confirm({ message: 'Add an icon now?', default: true }); let icon_path = ''; - if (want_icon) { + if (iconSource === 'ghost' && ghostIcon) { + await fs.writeFile(ICON_STORE_PATH, ghostIcon.buffer); + icon_path = ICON_STORE_PATH; + } else if (iconSource === 'file') { icon_path = await input({ message: 'Icon path inside the container', default: '/data/assets/icon.png', - validate: validateIconPath, + validate: validateLocalIconPath, }); + const dims = await localIconDimensions(icon_path); + if (dims) warnIfBelowRecommended(dims.width, dims.height); } - // ------------- 5. Review ------------- - console.log(pc.bold('\n5. Review')); + // ─────────────────────────────────────────────────────────────────────── + // 6. Review + // ─────────────────────────────────────────────────────────────────────── + console.log(pc.bold('\n6. Review')); console.log(` PDS: ${pc.cyan(atproto_service)}`); console.log(` Handle: ${pc.cyan(atproto_handle)}`); console.log(` Ghost URL: ${pc.cyan(ghost_url)}`); + console.log(` Bridge URL: ${pc.cyan(bridge_url)}`); console.log(` Publication: ${pc.cyan(publication_name)}`); if (publication_description) console.log(` Description: ${pc.dim(publication_description)}`); - console.log(` Theme: ${theme ? pc.cyan(preset) + pc.dim(` (${theme.accent} accent)`) : pc.dim('skip')}`); + console.log(` Theme: ${theme ? pc.cyan(preset) + pc.dim(` (accent ${theme.accent})`) : pc.dim('skip')}`); console.log(` Icon: ${icon_path ? pc.cyan(icon_path) : pc.dim('skip')}`); const ok = await confirm({ message: 'Save this configuration?', default: true }); if (!ok) { - console.log(pc.yellow('\nCancelled — nothing was saved.')); + console.log(pc.yellow('\nCancelled — nothing was saved (beyond the Ghost credentials needed to fetch settings).')); return; } - // ------------- 6. Validate live ------------- - const v1 = ora('Logging into PDS…').start(); - try { - const { AtpAgent } = await import('@atproto/api'); - const agent = new AtpAgent({ service: atproto_service }); - await agent.login({ identifier: atproto_handle, password: atproto_app_password }); - v1.succeed(`PDS login OK (${pc.dim(agent.session?.did ?? '?')})`); - } catch (err) { - v1.fail('PDS login failed'); - throw err; - } - - const v2 = ora('Pinging Ghost Admin API…').start(); - try { - const { default: GhostAdminAPI } = await import('@tryghost/admin-api'); - const client = new GhostAdminAPI({ url: ghost_url, key: ghost_admin_api_key, version: 'v5.0' }); - await client.posts.browse({ limit: 1 }); - v2.succeed('Ghost Admin API OK'); - } catch (err) { - v2.fail('Ghost Admin API call failed'); - throw err; - } - - // ------------- 7. Persist ------------- - // Webhook secret is auto-generated so the operator never has to invent one; - // re-running setup regenerates and invalidates the old webhooks (intentional — - // forces the new value into Ghost's UI). + // ─────────────────────────────────────────────────────────────────────── + // 7. Persist + // ─────────────────────────────────────────────────────────────────────── const webhook_secret = randomBytes(32).toString('hex'); setConfig('atproto_service', atproto_service); setConfig('atproto_handle', atproto_handle); setConfig('atproto_app_password', atproto_app_password); - setConfig('ghost_url', ghost_url); - setConfig('ghost_admin_api_key', ghost_admin_api_key); setConfig('bridge_url', bridge_url.replace(/\/$/, '')); setConfig('publication_name', publication_name); setConfig('publication_description', publication_description); @@ -221,21 +303,23 @@ export async function runSetup(): Promise { setConfig('webhook_secret', webhook_secret); setConfig('setup_complete', 'true'); - // ------------- 8. Bootstrap the publication record on the PDS ------------- + // ─────────────────────────────────────────────────────────────────────── + // 8. Bootstrap the publication record on the PDS + // ─────────────────────────────────────────────────────────────────────── const v3 = ora('Bootstrapping site.standard.publication on the PDS…').start(); let publicationUri: string; try { const result = await upsertPublication(); publicationUri = result.uri; - v3.succeed( - `Publication record ${result.created ? 'created' : 'updated'} — ${pc.dim(result.uri)}`, - ); + v3.succeed(`Publication record ${result.created ? 'created' : 'updated'} — ${pc.dim(result.uri)}`); } catch (err) { v3.fail('Publication bootstrap failed'); throw err; } - // ------------- 9. Next steps ------------- + // ─────────────────────────────────────────────────────────────────────── + // 9. Next steps — three pieces of glue + // ─────────────────────────────────────────────────────────────────────── const webhookEndpoint = `${bridge_url.replace(/\/$/, '')}/webhooks/ghost`; const wellKnownPath = `${ghost_url.replace(/\/$/, '')}/.well-known/site.standard.publication`; @@ -264,6 +348,6 @@ export async function runSetup(): Promise { console.log(' ' + pc.green(``)); console.log(''); - console.log(pc.dim('Backfill existing posts with: ') + pc.cyan('ghoststandard backfill')); + console.log(pc.dim('Backfill existing posts with: ') + pc.cyan('gs backfill')); console.log(''); } diff --git a/src/lib/ghost.ts b/src/lib/ghost.ts index 80d14e9..521b5e0 100644 --- a/src/lib/ghost.ts +++ b/src/lib/ghost.ts @@ -47,6 +47,68 @@ export async function browsePublishedPosts(): Promise { }); } +// --------------------------------------------------------------------------- +// Ghost site settings — used by the setup wizard to pre-fill defaults for +// publication name / description / theme accent / icon. Ghost stores all of +// these in one place; the wizard ingests them so the operator mostly hits +// Enter through the publication metadata section. +// --------------------------------------------------------------------------- + +export interface GhostSettings { + title: string | null; + description: string | null; + accent_color: string | null; // hex like #FF5C1F + iconUrl: string | null; // absolute URL ready for fetch +} + +export async function fetchGhostSettings(): Promise { + const client = getClient(); + const raw = await client.settings.browse(); + const iconUrl = (() => { + if (!raw?.icon) return null; + const url = String(raw.icon); + if (url.startsWith('http://') || url.startsWith('https://')) return url; + const base = (getConfig('ghost_url') ?? '').replace(/\/$/, ''); + return base + (url.startsWith('/') ? url : `/${url}`); + })(); + return { + title: raw?.title ?? null, + description: raw?.description ?? null, + accent_color: raw?.accent_color ?? null, + iconUrl, + }; +} + +// Downloads Ghost's publication icon (if any), reports its dimensions + mime +// type so the wizard can present it as a default. Returns null if Ghost has +// no icon, the download fails, or sharp can't parse the bytes. +export async function fetchGhostIcon(): Promise< + { buffer: Buffer; mimeType: string; width: number; height: number } | null +> { + const settings = await fetchGhostSettings(); + if (!settings.iconUrl) return null; + + let buffer: Buffer; + let mimeType: string; + try { + const resp = await fetch(settings.iconUrl); + if (!resp.ok) return null; + buffer = Buffer.from(await resp.arrayBuffer()); + mimeType = resp.headers.get('content-type') ?? 'image/png'; + } catch { + return null; + } + + try { + const sharp = (await import('sharp')).default; + const meta = await sharp(buffer).metadata(); + if (!meta.width || !meta.height) return null; + return { buffer, mimeType, width: meta.width, height: meta.height }; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // codeinjection_head marker block + idempotent strip/append // --------------------------------------------------------------------------- diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 72d49e8..457f4d6 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -1,8 +1,9 @@ -// Shared theme palette presets — used by `ghoststandard setup` and -// `ghoststandard set-theme`. +// Theme helpers — accent-parameterized so the wizard can derive the palette +// from Ghost's existing accent_color instead of hardcoded brand orange. // -// 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). +// site.standard.theme.basic is four colors: background, foreground, accent, +// accentForeground. We fix the background/foreground per "light" vs "dark" +// surface and let the accent vary per-publication. export type ThemeColors = { background: string; @@ -11,10 +12,30 @@ export type ThemeColors = { 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' }, -}; +const LIGHT_BG = '#FAFAFA'; +const LIGHT_FG = '#231F20'; +const DARK_BG = '#1A1A1A'; +const DARK_FG = '#E6E6E6'; +const ON_ACCENT = '#FFFFFF'; + +export const FALLBACK_ACCENT = '#FF5C1F'; + +export function makeLightTheme(accent: string): ThemeColors { + return { + background: LIGHT_BG, + foreground: LIGHT_FG, + accent, + accentForeground: ON_ACCENT, + }; +} + +export function makeDarkTheme(accent: string): ThemeColors { + return { + background: DARK_BG, + foreground: DARK_FG, + accent, + accentForeground: ON_ACCENT, + }; +} export const HEX_RE = /^#[0-9a-fA-F]{6}$/;