opencode-config/.opencode/draw-image/cli.ts
Sergey 39de98dc95
fix(draw-image): race condition on shared .tmp-render.svg breaks parallel renders (#178)
* fix(draw-image): use pid-based tmp svg and cleanup in finally

* test(draw-image): decouple e2e.optional from leftover tmp svg

* test(draw-image): add cleanup unit test for tmp svg lifecycle

* docs(draw-image): add handoff and ADR for tmp svg race fix

* docs(handoff): set PR number

* docs(project-map): update after structural changes

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-31 21:38:54 +03:00

90 lines
No EOL
2.7 KiB
TypeScript

import { writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import os from "node:os"
import { spawnSync } from "node:child_process"
import { loadBrand } from "./src/config.ts"
import { loadTemplate, buildSvg, computeHash } from "./src/render.ts"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function parseArgs(argv: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith("--")) {
const key = a.slice(2)
const val = argv[i + 1] ?? ""
out[key] = val
i++
}
}
return out
}
function main() {
const argv = process.argv.slice(2)
if (argv.length < 2 || argv[0] !== "render") {
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--out path]\n')
process.exit(2)
}
const template = argv[1]
const opts = parseArgs(argv.slice(2))
const title = opts.title ?? ""
const subtitle = opts.subtitle
const out = opts.out ?? "./assets/cover.png"
const slots: Record<string, string> = {}
if (opts.slots) {
for (const pair of opts.slots.split(",")) {
const eq = pair.indexOf("=")
if (eq !== -1) slots[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim()
}
}
const brand = loadBrand(__dirname)
const templateSvg = loadTemplate(__dirname, template)
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
const outResolved = path.resolve(out)
const metaPath = outResolved.replace(/\.png$/, ".meta.json")
if (existsSync(outResolved) && existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"))
if (meta.hash === hash) {
console.log(JSON.stringify({ path: outResolved, hash, status: "skipped" }))
return
}
} catch {
// stale meta, re-render
}
}
const svg = buildSvg(templateSvg, brand, { template, title, subtitle, slots, out }, __dirname)
const tmpSvg = path.join(os.tmpdir(), `draw-image-${process.pid}.svg`)
try {
writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(__dirname, "render.mjs"), tmpSvg, outResolved], {
encoding: "utf-8",
})
if (r.status !== 0) {
process.stderr.write(r.stderr || r.stdout || "render failed\n")
process.exit(1)
}
const meta = {
hash,
generatedAt: new Date().toISOString(),
size: 1024,
}
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n")
console.log(JSON.stringify({ path: outResolved, hash, status: "rendered" }))
} finally {
if (existsSync(tmpSvg)) rmSync(tmpSvg, { force: true })
}
}
main()