Deploy / build (push) Successful in 22s
EN + ZH devops gotcha post on debugging MySQL Workbench 26.7.0's failure to connect to MariaDB. Three patches to Oracle's bundled code, all the same root cause: `major >= 8` is not a valid MySQL-vs-MariaDB test. Also adds per-post OG + banner (TERMINALS/BANNERS entries).
685 lines
32 KiB
JavaScript
685 lines
32 KiB
JavaScript
#!/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 3–5 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 (W1–W5)',
|
||
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 5–50 → -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 ✓' },
|
||
],
|
||
};
|
||
|
||
BANNERS['self-hosted-speech-to-text-api'] = {
|
||
titlebar: 'root@gpu-pc — whisper.cpp',
|
||
lines: [
|
||
{ t: 'cmd', text: 'netstat -an | grep 20129' },
|
||
{ t: 'ok', text: 'TCP 127.0.0.1:20129 LISTENING ← only this PC' },
|
||
{ t: 'dim', text: 'iPhone · iPad · Android · work PCs ?' },
|
||
{ t: 'cmd', text: '--host 0.0.0.0 + firewall -RemoteAddress LocalSubnet' },
|
||
{ t: 'ok', text: 'TCP 0.0.0.0:20129 LISTENING ← reachable' },
|
||
{ t: 'cmd', text: 'n8n gate: Authorization header → per-device key' },
|
||
{ t: 'err', text: 'bad key → 403' },
|
||
{ t: 'hl', text: '{"text":"…"} large-v3 on RTX 3060 · 130 wpm' },
|
||
],
|
||
flow: [
|
||
{ n: '1', label: 'phone dictates' },
|
||
{ n: '2', label: 'HTTPS + key' },
|
||
{ n: '3', label: 'n8n gate' },
|
||
{ n: '4', label: 'GPU transcribe' },
|
||
{ n: '5', label: '5x typing ✓' },
|
||
],
|
||
};
|
||
|
||
BANNERS['replacing-rdpguard-with-ipban'] = {
|
||
titlebar: 'root@win11 — ipban service',
|
||
lines: [
|
||
{ t: 'cmd', text: 'sc.exe qc IPBAN' },
|
||
{ t: 'ok', text: 'START_TYPE : 2 AUTO_START ✓' },
|
||
{ t: 'dim', text: 'RdpGuard 7.8.7 — paid, closed, 3 versions behind' },
|
||
{ t: 'err', text: 'uninstall → rule rdpguard-… deleted → 12 bans LOST' },
|
||
{ t: 'cmd', text: 'ban.txt → IPBan import (12 ipv4)' },
|
||
{ t: 'ok', text: 'Updating firewall with 12 entries...' },
|
||
{ t: 'err', text: "ipban --install-service → 'Unrecognized command'" },
|
||
{ t: 'hl', text: '16 attackers blocked · zero protection gap' },
|
||
],
|
||
flow: [
|
||
{ n: '1', label: 'export bans' },
|
||
{ n: '2', label: 'sc.exe install' },
|
||
{ n: '3', label: 'whitelist LAN' },
|
||
{ n: '4', label: '16 blocked ✓' },
|
||
],
|
||
};
|
||
|
||
BANNERS['patching-workbench-26-for-mariadb'] = {
|
||
titlebar: 'root@win11 — workbench 26.7.0',
|
||
lines: [
|
||
{ t: 'cmd', text: 'workbench → mariadb 192.168.1.124:3306' },
|
||
{ t: 'err', text: 'TypeError: on_session_message() missing 1 arg' },
|
||
{ t: 'dim', text: 'the error handler crashed reporting the error' },
|
||
{ t: 'err', text: "ERROR 1193: Unknown system variable 'gtid_mode'" },
|
||
{ t: 'dim', text: 'MariaDB 10.11 → nversion 101119 · major >= 8 guard passes' },
|
||
{ t: 'cmd', text: 'patch replication.py · DbSession.py · SetupTasks.py' },
|
||
{ t: 'err', text: "ERROR 1193: 'explain_json_format_version' — again" },
|
||
{ t: 'hl', text: '4 connections · mysqlsh proved the server was fine' },
|
||
],
|
||
flow: [
|
||
{ n: '1', label: 'mysqlsh test' },
|
||
{ n: '2', label: 'fix handler' },
|
||
{ n: '3', label: '3 patches' },
|
||
{ n: '4', label: 'connected ✓' },
|
||
],
|
||
};
|
||
|
||
// ---------- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
// 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) || ' '}</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}`); |