// lib/pdf-render.ts // SERVER-ONLY — bridges the Workflow app to the patched minimax-pdf skill. // // Why: minimax-pdf produces print-quality branded PDFs (Playfair Display, navy // accent rules, alternating row tints). The skill lives in the ysterbaard // profile at ~/.hermes/profiles/ysterbaard/skills/minimax-pdf — installed with // auto h2 keepWithNext (no orphan headings) + an explicit `keep_together` // block type. // // Fallback order: // 1. Profile skill path (preferred, has David's edits). // 2. Default skill path (~/.hermes/skills/minimax-pdf, symlinked in this env). // 3. Throw with a clear "skill not installed" error. import "server-only"; import { spawn } from "child_process"; import { promises as fs } from "fs"; import os from "os"; import path from "path"; const HOME = os.homedir(); const SKILL_PATHS = [ path.join(HOME, ".hermes/profiles/ysterbaard/skills/minimax-pdf"), path.join(HOME, ".hermes/skills/minimax-pdf"), ]; export interface RenderPdfOptions { /** Title shown on the cover page. */ title: string; /** Subtitle / company-or-director name shown on the cover. */ author: string; /** Date string for the cover (e.g. "30 August 2026"). */ date: string; /** Hex color, e.g. "#1C3A5E". Deep navy = legal/compliance. */ accent: string; /** Document type — see skill (report/proposal/etc). "report" is the default. */ type?: "report" | "proposal" | "general" | "minimal"; /** minimax-pdf content.json: a flat array of blocks. */ blocks: unknown[]; /** Absolute path for the rendered PDF. */ outPath: string; } function pickSkill(): string { for (const p of SKILL_PATHS) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports require("fs").accessSync(path.join(p, "scripts/make.sh")); return p; } catch { /* try next */ } } throw new Error( `minimax-pdf skill not installed at any of: ${SKILL_PATHS.join(", ")}. ` + "Copy the skill into the ysterbaard profile and re-deploy." ); } function run(cmd: string, args: string[], cwd: string): Promise<{ ok: boolean; stderr: string }> { return new Promise((resolve) => { const child = spawn(cmd, args, { cwd }); let stderr = ""; child.stderr.on("data", (b) => (stderr += b.toString())); child.on("error", (err) => resolve({ ok: false, stderr: err.message })); child.on("close", (code) => resolve({ ok: code === 0, stderr })); }); } export async function renderPdf(opts: RenderPdfOptions): Promise { const skillRoot = pickSkill(); const makeSh = path.join(skillRoot, "scripts/make.sh"); // Write content.json to a temp file in the skill's working dir // (make.sh reads it relative to cwd). const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "wf-report-")); const contentPath = path.join(tmpDir, "content.json"); await fs.writeFile(contentPath, JSON.stringify(opts.blocks, null, 2)); try { const result = await run("bash", [ makeSh, "run", "--title", opts.title, "--type", opts.type || "report", "--author", opts.author, "--date", opts.date, "--accent", opts.accent, "--content", contentPath, "--out", opts.outPath, ], tmpDir); if (!result.ok) { throw new Error(`minimax-pdf render failed: ${result.stderr.slice(0, 800)}`); } return await fs.readFile(opts.outPath); } finally { await fs.unlink(contentPath).catch(() => {}); await fs.rmdir(tmpDir).catch(() => {}); } }