Files
hoelee-blog/scripts/banner-gen/generate.mjs
T
hoelee 044ded45b3
Deploy / build (push) Successful in 19s
Add TRIM corruption sequel post + publish SATA cable post (en/zh, og+banner)
- New post: 'The Corruption Came Back on Different Drives — the Cause Was
  TRIM, Not the SSDs' — same 0x8941f998 zeros fingerprint on a second drive
  stack (IronWolf 110 SATA pair), root cause queued TRIM (FreeBSD gag 264139),
  fix diskAutotrim=off + nodiscard, scrub evidence, enterprise SATA buying
  guidance. en + zh, custom OG + banner.
- Publish formerly-draft SATA cable post (draft:false) with OG + banner.
- Correct drive identity in both: ZA960NM10001 is Seagate IronWolf 110, not
  'Samsung PM9A3' (PM9A3 is NVMe; FPDMA errors are SATA-only).
- Old RAID post (en+zh): cross-link to sequel, fix 'a Samsung' -> IronWolf 110.
2026-09-18 05:38:13 +08:00

624 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* banner-gen — generate a 1600×900 (16:9) banner/hero image for a blog post.
*
* Usage:
* node scripts/banner-gen/generate.mjs <slug> [--all]
*
* Reads the post's frontmatter, fills the template, renders with headless
* Chrome, and writes public/banners/<slug>.png.
*
* Each post's content (titlebar, terminal lines, flow steps) is defined in
* BANNERS below, keyed by slug. Falls back to a generic default.
*
* The post frontmatter then needs `banner: /banners/<slug>.png` so the
* article renders it above the title ([...slug].astro does this).
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const slug = process.argv[2];
if (!slug) {
console.error('Usage: node scripts/banner-gen/generate.mjs <slug> | --all');
process.exit(1);
}
// ---------- per-post banner content ----------
// lines: [{ t: 'cmd'|'dim'|'err'|'ok'|'hl'|'prompt', text }]
// flow: [{ n: '1', label: '...', err?: true }]
const BANNERS = {
'why-telegram-bot-notifications-die': {
titlebar: 'root@dsm — carousell-monitor',
lines: [
{ t: 'dim', text: '2026-09-08 20:45:41' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'scraping "uniform" — 49 cards parsed' },
{ t: 'dim', text: '2026-09-08 20:45:42' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '2 new listings → archiving to NocoDB' },
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendPhoto failed: 400 nginx/1.30.1' },
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendMessage failed: 400 nginx/1.30.1' },
{ t: 'prompt', text: '$', },
{ t: 'cmd', text: 'getent hosts api.telegram.org' },
{ t: 'hl', text: '2001:67c:4e8:f004::9' }, { t: 'dim', text: 'api.telegram.org ← IPv6 only, no A record' },
{ t: 'prompt', text: '$' },
{ t: 'cmd', text: 'fix = extra_hosts → IPv4 · multipart join → binary-safe' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ delivered ✓' },
],
flow: [
{ n: '1', label: 'Collect listings' },
{ n: '2', label: 'sendPhoto 400', err: true },
{ n: '3', label: 'DNS → IPv6 only' },
{ n: '4', label: 'multipart fix' },
{ n: '5', label: 'delivered ✓' },
],
},
'authentik-major-upgrade-gotchas': {
titlebar: 'root@dsm — authentik upgrade',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose pull authentik-worker' },
{ t: 'dim', text: 'Pulling authentik:2026.8.1 ... done' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose up -d && docker logs -f authentik' },
{ t: 'err', text: 'authorization_flow not found · SSO broken' },
{ t: 'dim', text: 'trusted_proxy_cidrs was reset · storage mount changed' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = authorization_flow → authentication_flow' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ SSO restored ✓' },
],
flow: [
{ n: '1', label: '2025.8 → 2026.8' },
{ n: '2', label: 'SSO broken', err: true },
{ n: '3', label: 'flow renamed' },
{ n: '4', label: 'trusted proxy' },
{ n: '5', label: 'restored ✓' },
],
},
'using-chinese-llm-apis-from-malaysia': {
titlebar: 'root@dsm — llm cost watch',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'deepseek-v4-flash · off-peak' },
{ t: 'dim', text: '$0.15 / 1M in · $0.60 / 1M out — cached input ~20× less' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'tokenrhythm ¥68 → GLM-5.3 · Qwen3.8-Max · Kimi K2.7-Code' },
{ t: 'err', text: 'wall: RMB payment · mainland CN phone verification' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = Alipay top-up · email-signup platforms' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ ~US$10 credit ≈ a month of agent work ✓' },
],
flow: [
{ n: '1', label: 'RMB via Alipay' },
{ n: '2', label: 'CN phone wall', err: true },
{ n: '3', label: 'OpenAI-compatible key' },
{ n: '4', label: '¥68 credit' },
{ n: '5', label: 'agents run ✓' },
],
},
'how-i-host-this-blog': {
titlebar: 'root@unraid — deploy pipeline',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git push origin main' },
{ t: 'dim', text: '→ git.hoelee.com/hoelee/hoelee-blog' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'Gitea Actions → build (Astro 5)' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'npm ci && npm run build → dist/' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'deploy → unRaid runner → nginx' },
{ t: 'ok', text: '✓ served via Cloudflare (cache + SSL)' },
{ t: 'dim', text: 'git-as-CMS · zero-downtime deploy' },
],
flow: [
{ n: '1', label: 'git push' },
{ n: '2', label: 'Gitea Actions' },
{ n: '3', label: 'Astro build' },
{ n: '4', label: 'unRaid nginx' },
{ n: '5', label: 'Cloudflare' },
],
},
'how-i-built-the-digikedai-telegram-bot': {
titlebar: 'DigiKedai — AI support bot',
lines: [
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "does this course have a free trial?"' },
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: yes — here\'s your trial account ✓' },
{ t: 'dim', text: '(answered in < 2s, no human in the loop)' },
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "which package should I buy?"' },
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: recommends + provisions a free account' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stack = grammY · LiteLLM · Cloudflare tunnel · 24/7' },
],
flow: [
{ n: '1', label: 'User asks' },
{ n: '2', label: 'grammY webhook' },
{ n: '3', label: 'LiteLLM' },
{ n: '4', label: 'AI answers' },
{ n: '5', label: 'provisions ✓' },
],
},
'when-smart-says-healthy-but-your-raid-is-corrupting-data': {
titlebar: 'root@unraid — cache pool scrub',
lines: [
{ t: 'dim', text: 'btrfs RAID1 · 2× Samsung PM9A3 NVMe · same batch' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'btrfs scrub /mnt/cache' },
{ t: 'err', text: 'csum 0x8941f998 recurring = CRC32C(zero block)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'self-heal rewrite → does NOT stick' },
{ t: 'err', text: 'write-path corruption · SMART stays clean' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = restore VM + replace both drives' },
],
flow: [
{ n: '1', label: 'SMART clean' },
{ n: '2', label: 'csum zeros', err: true },
{ n: '3', label: 'self-heal no-stick' },
{ n: '4', label: 'restore VM' },
{ n: '5', label: 'replace both ✓' },
],
},
'self-hosting-mem0-memory-stack': {
titlebar: 'root@dsm — mem0 memory stack',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "curl -X POST :20015/memories -H X-Api-Key" },
{ t: 'dim', text: 'user_id + text → mem0 extracts & stores' },
{ t: 'prompt', text: '¶' }, { t: 'ok', text: '→ remembers across sessions ✓' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stack = mem0-api + LiteLLM + pgvector(pg17)' },
{ t: 'err', text: 'infer=true → LLM hop · slow writes' },
{ t: 'prompt', text: '' }, { t: 'ok', text: 'self-hosted · data stays on LAN ✓' },
],
flow: [
{ n: '1', label: 'POST /memories' },
{ n: '2', label: 'store fact' },
{ n: '3', label: 'search' },
{ n: '4', label: 'feed context' },
{ n: '5', label: 'remembers ✓' },
],
},
'hardening-a-tor-onion-service': {
titlebar: 'root@tor-host — onion service audit',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'wget -qO- ipv4.icanhazip.com # from inside the app' },
{ t: 'err', text: '→ home IP returned · iptables block silently gone' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'cap_add: NET_BIND_SERVICE · keep :80' },
{ t: 'err', text: 'listen tcp :80: bind: permission denied · CapEff=0' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'networks: internal:true · port 8080 · SocksPort 0' },
{ t: 'ok', text: '→ bad address ✓ · zero egress ✓ · all containers healthy ✓' },
],
flow: [
{ n: '1', label: 'Audit egress', err: true },
{ n: '2', label: 'internal:true' },
{ n: '3', label: 'non-root :8080' },
{ n: '4', label: 'healthchecks' },
{ n: '5', label: 'verify ✓' },
],
},
'hello-world': {
titlebar: '~/hoelee-blog — first commit',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git init hoelee-blog && git add -A' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git commit -m "Hello, world"' },
{ t: 'ok', text: '[main 0000001] Hello, world ✓' },
{ t: 'dim', text: 'technical writing · self-hosting · build log' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'about → blog.hoelee.com' },
],
flow: [
{ n: '1', label: 'Why this blog' },
{ n: '2', label: 'what I build' },
{ n: '3', label: 'self-hosting' },
{ n: '4', label: 'learn in public' },
],
},
'why-your-headless-browser-cant-scrape-everything': {
titlebar: 'root@dsm — headless browser vs anti-bot',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'browserless → goofish.com search?q=iPhone15' },
{ t: 'err', text: '非法访问 · "please use a normal browser"' },
{ t: 'dim', text: 'stealth flag · patched navigator.webdriver · real UA → still blocked' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl carousell search → parse __PRELOADED_STATE__' },
{ t: 'ok', text: 'listings + price + photos ✓ (no browser, no stealth)' },
{ t: 'hl', text: 'server-rendered JSON ≠ signed async API' },
{ t: 'prompt', text: '' }, { t: 'ok', text: 'read the data path before choosing a tool ✓' },
],
flow: [
{ n: '1', label: 'goofish' },
{ n: '2', label: 'signed API', err: true },
{ n: '3', label: 'carousell' },
{ n: '4', label: 'server JSON', err: false },
{ n: '5', label: 'parsed ✓' },
],
},
'the-nocodb-attachment-that-wouldnt-update': {
titlebar: 'root@dsm — nocodb backfill',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'PATCH image path = fullsize_url' },
{ t: 'dim', text: '200 OK — but readback still shows the old thumbnail' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'grep image.json | jq .id' },
{ t: 'hl', text: 'id present → NocoDB resolves by id, ignores new path' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'drop id + signedPath · url.replace("_progressive_thumbnail","")' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '300 rows updated ✓' },
],
flow: [
{ n: '1', label: 'patch path' },
{ n: '2', label: 'id keeps old', err: true },
{ n: '3', label: 'strip id' },
{ n: '4', label: 'replace() suffix' },
{ n: '5', label: 'updated ✓' },
],
},
'how-to-verify-a-hosting-provider-before-you-buy': {
titlebar: '~/hosting-due-diligence',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'whois vps.tld | grep -i created' },
{ t: 'err', text: 'registration: 2026-05 — homepage says \"since 2012\"' },
{ t: 'dim', text: 'a \"since\" claim on a young domain is never verifiable' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl AUP | grep -iE \"tor|reverse proxy|tunnel\"' },
{ t: 'hl', text: 'found: \"TOR nodes\", \"anonymizing services\" → hard no' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'trustpilot trend · r/hosting · retention clause' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ verdict ✓' },
],
flow: [
{ n: '1', label: 'domain age' },
{ n: '2', label: '\"since\" claim', err: true },
{ n: '3', label: 'read AUP' },
{ n: '4', label: 'reputation' },
{ n: '5', label: 'verdict ✓' },
],
},
'how-i-vetted-20-vps-providers-with-parallel-subagents': {
titlebar: '~/vendor-due-diligence — fan-out',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'delegate → 3 subagents, 20 providers' },
{ t: 'dim', text: 'batch A: 5 · batch B: 6 · batch C: 9 (parallel)' },
{ t: 'info', text: 'each → WHOIS · AUP · privacy · pricing · reviews' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'merge → one scorecard' },
{ t: 'hl', text: 'aup flags: "TOR nodes" · domain-age mismatch · metered cap' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ ~20 providers audited in 3h ✓' },
],
flow: [
{ n: '1', label: 'checklist' },
{ n: '2', label: '3 subagents' },
{ n: '3', label: 'parallel fetch' },
{ n: '4', label: 'scorecard' },
{ n: '5', label: 'verdict ✓' },
],
},
'automating-cyberpanel-without-the-ui': {
titlebar: 'root@cyberpanel — reverse-engineering the v2 API',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -X POST .../api/verifyConnection' },
{ t: 'err', text: '404 — the /api/ prefix was dropped in v2' },
{ t: 'dim', text: 'docs say adminUser/adminPass · panel says "This request need session."' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET / → csrftoken → POST /verifyLogin (X-CSRFToken)' },
{ t: 'hl', text: 'loginStatus: 1 · session cookie set' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST /websites/fetchWebsitesList' },
{ t: 'ok', text: '→ all sites + SSL expiry ✓ (no UI)' },
],
flow: [
{ n: '1', label: 'docs 404', err: true },
{ n: '2', label: 'session wall' },
{ n: '3', label: 'CSRF token' },
{ n: '4', label: 'verifyLogin' },
{ n: '5', label: 'sites ✓' },
],
},
'syncing-a-self-improving-ai-agent-across-machines': {
titlebar: '~/hermes — sync agent across machines',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git pull --ff-only origin main' },
{ t: 'err', text: 'refusing: local divergence · never --force' },
{ t: 'dim', text: 'distribution repo skills/ is a COPY, not the live dir' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'diff -rq live ~/skills repo copy' },
{ t: 'hl', text: 'Only in live/: hermes-profile-sync ← created after last push' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stage real skills only · commit · push · verify' },
{ t: 'ok', text: '→ edbf7ac on both ends ✓ · 172 skills · 49MB junk stripped' },
],
flow: [
{ n: '1', label: 'pull --ff-only' },
{ n: '2', label: 'diff 3-way', err: true },
{ n: '3', label: 'merge / ask' },
{ n: '4', label: 'push' },
{ n: '5', label: 'verify ✓' },
],
},
'ai-furniture-compositing-with-flux-kontext': {
titlebar: 'client — furniture compositing PoC',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST fal-ai/flux-pro/kontext/multi · 2 photos in' },
{ t: 'err', text: 'toDataURL: tainted canvas · may not be exported' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = same-origin paths · enhance_prompt:false · name objects' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 1 staged room out ✓ ($0.04, ~17s)' },
],
flow: [
{ n: '1', label: '2 photos in' },
{ n: '2', label: 'tainted canvas', err: true },
{ n: '3', label: 'same-origin fix' },
{ n: '4', label: 'prompt anchoring' },
{ n: '5', label: '1 room out ✓' },
],
},
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': {
titlebar: 'wp-admin — AI Remix Photo plugin',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'wp plugin list · hre-ai-remix 0.0.1 → 0.0.22' },
{ t: 'err', text: '404 …/v1admin/photos — rest_url() has no trailing slash' },
{ t: 'dim', text: 'routes in is_admin() ✗ · watermark silent-fallback ✗' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "join REST with '/admin/…' · log root cause · warn admin" },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 22 releases · 58 commits · 4 were the AI ✓' },
],
flow: [
{ n: '1', label: 'PoC → plugin' },
{ n: '2', label: 'v1admin 404', err: true },
{ n: '3', label: 'silent watermark', err: true },
{ n: '4', label: 'server-side gates' },
{ n: '5', label: '0.0.22 ✓' },
],
},
'best-ai-video-generators-2026': {
titlebar: '~/blog — AI video free vs paid 2026',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'compare cloud APIs vs open weights' },
{ t: 'err', text: '"free" = 4 different deals · credits ≠ seconds' },
{ t: 'dim', text: 'Sora 2 app gone 04-26 · API sunset 09-24' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'LTX-2.3 via PinkCherry · demo clip ↓' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 11 tools · pricing checked 2026-09-11 ✓' },
],
flow: [
{ n: '1', label: 'free credits' },
{ n: '2', label: 'cloud APIs' },
{ n: '3', label: 'open weights' },
{ n: '4', label: 'LTX-2.3 test' },
{ n: '5', label: 'pick one ✓' },
],
},
'how-i-made-my-own-songs-with-suno-ai': {
titlebar: 'hoelee@studio — suno v6 · 7 songs',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'write lyrics · craft style prompt (200 chars)' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '[Verse][Chorus][Bridge] · [Whispered][Belted]' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'generate 35 takes → keep best' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'AI vocalist mispronounces 忘川 — respell test clip' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'v6: section edit · single-line swap · voices' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 7 songs hosted · embedded above ✓' },
],
flow: [
{ n: '1', label: 'lyrics first' },
{ n: '2', label: 'style prompt' },
{ n: '3', label: 'metatags' },
{ n: '4', label: 'iterate takes' },
{ n: '5', label: 'ship ✓' },
],
},
'passbolt-hang-three-failure-modes': {
titlebar: 'root@dsm — passbolt incident',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET / → 504 Gateway Timeout (every minute, worse under load)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'cron: job is still running since ... (1m elapsed)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'SMTP Setting errors: fingerprint null' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = bind-mount passbolt.php · full fingerprint · remove ssl.force' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ cron 0.4s · UI 302 · healthcheck green ✓' },
],
flow: [
{ n: '1', label: '504 hang' },
{ n: '2', label: 'fingerprint null', err: true },
{ n: '3', label: 'mount config' },
{ n: '4', label: 'redirect loop', err: true },
{ n: '5', label: 'fixed ✓' },
],
},
'unraid-stop-array-hangs-on-swapfile': {
titlebar: 'root@unraid — array stop incident',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'WebUI → Stop array · swapfile lives on /mnt/cache (btrfs RAID1)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Retry unmounting user shares… · umount: target is busy (every 5s, forever)' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: '/proc/swaps lists /dev/loop0 — grep swapfile never matches' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = User Scripts: swapoff -a + losetup -j/-d at stopping_svcs · swapon at disks_mounted' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ clean unmount on first try · swap survives stop/start ✓' },
],
flow: [
{ n: '1', label: 'stop array' },
{ n: '2', label: 'EBUSY loop', err: true },
{ n: '3', label: 'losetup -j' },
{ n: '4', label: 'swapoff hook' },
{ n: '5', label: 'clean stop ✓' },
],
},
'the-cause-was-trim-not-the-ssds': {
titlebar: 'root@unraid — ssd pool trim watch',
lines: [
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'raw read error rate (failing now) is 19665 — sdd SMART trip' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'btrfs device stats /mnt/ssd' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'corruption_errs sdd1=27 sdb1=31 · csum 0x8941f998 = CRC32C(zeros) · both mirrors' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = diskAutotrim="off" · remount,nodiscard · scrub' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ scrub #2: corrected 0 · counters flat · cause = queued TRIM firmware bug ✓' },
],
flow: [
{ n: '1', label: 'SMART trip' },
{ n: '2', label: 'zeros on both mirrors', err: true },
{ n: '3', label: 'queued TRIM' },
{ n: '4', label: 'autotrim off' },
{ n: '5', label: 'scrub clean ✓' },
],
},
'that-dying-ssd-was-just-a-bad-sata-cable': {
titlebar: 'root@unraid — sdd mkfs attempt',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'mkfs.btrfs -K -f /dev/sdd1' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'ata7.00: WRITE FPDMA QUEUED timeout · NCQ disabled · lost async page write' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'ERROR: superblock magic doesn\'t match · smartctl -H timeout' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = new SATA cable + different port · rerun the same mkfs' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ clean format · 8 GiB fio verify=crc32c err=0 · device stats all zero ✓' },
],
flow: [
{ n: '1', label: '"dying" SSD' },
{ n: '2', label: 'FPDMA timeouts', err: true },
{ n: '3', label: 'cable/port swap' },
{ n: '4', label: 'rerun mkfs' },
{ n: '5', label: 'clean ✓' },
],
},
};
const DEFAULT_BANNER = {
titlebar: 'root@host — shell',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'engineering · devops · self-hosting' },
{ t: 'prompt', text: '' }, { t: 'ok', text: 'read the full post →' },
],
flow: [
{ n: '1', label: 'start' }, { n: '2', label: 'work' }, { n: '3', label: 'ship' },
],
};
BANNERS['n8n-v1-to-v2-upgrade-gotchas'] = {
titlebar: 'root@dsm — n8n v1 → v2 migration',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker pull n8nio/n8n:2.40.1 · container up in 90s' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'v1.123.x → v2.40.1 · 17 workflows · 7 active' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Telemetry failed schema validation: executions_data_save_on_error' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Failed to start Python task runner — Python 3 missing' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Sandbox: enabled=false (DB override; env was enabled=true)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'N8N_WEBHOOK_URL · pin TASK_TIMEOUT=300 · pin compression limits' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'storage rename flagged for v3 · migrate + remount together' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 7 deprecations resolved · downstream workflows intact ✓' },
],
flow: [
{ n: '1', label: 'pull v2' },
{ n: '2', label: 'read boot log' },
{ n: '3', label: 'schema reject', err: true },
{ n: '4', label: 'pin defaults' },
{ n: '5', label: 'verified ✓' },
],
};
BANNERS['self-healing-digital-goods-entitlements'] = {
titlebar: 'root@dsm — entitlement lifecycle (W1W5)',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'nocodb webhook → n8n compute → alist role scopes' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'desired = union(active purchases, direct grants) · MAX expiry wins' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'shared/effective-grants.js inlined at build · 4 workflows' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'public hostname: >60s → nginx 504 → 1s retries → pool → 503' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'http://nocodb:10380 (30ms, no tunnel) → cascade gone' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W3 sweeps every 5min · W4 repairs drift 03:00 + verifies' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W5 public: CORS-locked, minimal fields, no HMAC theatre' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ repair verified · reconcile log empty when healthy ✓' },
],
flow: [
{ n: '1', label: 'grant' },
{ n: '2', label: 'expire' },
{ n: '3', label: 'drift', err: true },
{ n: '4', label: 'repair' },
{ n: '5', label: 'verify ✓' },
],
};
BANNERS['running-tts-as-a-service-with-token-sidecars'] = {
titlebar: 'root@dsm — tts service · 1 year uptime',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'reader → GET /webhook/{mtts,gtts}?pass=…&text=…&speed=…' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'azure speech F0 · google cloud cmn-CN-Wavenet-A' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'token stored in workflow → 401 after 10min (azure) / 1h (google)' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: '2 cron sidecars → accesstoken.txt on shared volume' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'refresh 570s / 3500s · margin before expiry' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'map speed 550 → -20%…+150% · strip trailing newline' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '8 neural voices selected by integer index' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ secrets in zero workflows · still running ✓' },
],
flow: [
{ n: '1', label: 'webhook' },
{ n: '2', label: 'read token' },
{ n: '3', label: 'synthesize' },
{ n: '4', label: 'audio/wav' },
{ n: '5', label: '1yr ✓' },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
if (existsSync(postPath)) {
const raw = readFileSync(postPath, 'utf8');
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const c = fm.match(/^category\s*:\s*(.+)$/m)?.[1]?.trim();
if (c) category = c.replace(/["']/g, '');
}
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// render terminal lines
function renderLines(lines) {
// group into visual lines: each entry has t + text; consecutive dim/prompt/cmd share a row
let html = '';
let i = 0;
while (i < lines.length) {
const l = lines[i];
if (l.t === 'prompt') {
// prompt may be followed by a cmd/err/ok/hl/dim on same row
const next = lines[i + 1];
const promptClass = l.text === '$' ? 'prompt' : 'prompt';
let row = `<span class="${promptClass}">${esc(l.text) || '&nbsp;'}</span>`;
if (next && next.t !== 'prompt') {
row += `<span class="${next.t}">${esc(next.text)}</span>`;
i += 1;
}
html += `<div class="line" style="margin-top:6px;">${row}</div>`;
} else {
html += `<div class="line" style="margin-top:6px;"><span class="${l.t}">${esc(l.text)}</span></div>`;
}
i += 1;
}
return html;
}
function renderFlow(flow) {
let html = '';
flow.forEach((s, idx) => {
html += `<div class="step${s.err ? ' err-step' : ''}"><span class="num">${s.n}</span>${esc(s.label)}</div>`;
if (idx < flow.length - 1) html += `<div class="arrow">→</div>`;
});
return html;
}
const cfg = BANNERS[slug] || DEFAULT_BANNER;
const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/');
let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8');
tpl = tpl
.replace('{{CATEGORY}}', esc(category))
.replace('{{SLUG}}', esc(slug))
.replace('{{LOGO_PATH}}', logoPath)
.replace('{{TITLEBAR}}', esc(cfg.titlebar))
.replace('{{TERMINAL_HTML}}', renderLines(cfg.lines))
.replace('{{FLOW_HTML}}', renderFlow(cfg.flow));
mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true });
const htmlPath = join(ROOT, '.og', 'gen', `${slug}-banner.html`);
writeFileSync(htmlPath, tpl);
const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const outPng = join(ROOT, '.og', 'gen', `${slug}-banner.png`);
try {
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1600,900 --virtual-time-budget=3000 --screenshot="${outPng}" "file:///${htmlPath.replace(/\\/g, '/')}"`, { stdio: 'pipe' });
} catch (e) {
console.error('Chrome render failed:', e.message);
process.exit(1);
}
const publicDir = join(ROOT, 'public', 'banners');
mkdirSync(publicDir, { recursive: true });
const finalPng = join(publicDir, `${slug}.png`);
execSync(`copy /Y "${outPng}" "${finalPng}"`, { stdio: 'pipe' });
console.log(`✓ Banner generated: public/banners/${slug}.png`);
console.log(` category: ${category}`);