fix(draw-image): optional badge/subtitle + self-healing docker entrypoint (#144)

* fix(draw-image): conditional slot bg + optional subtitle in buildSvg

* test(draw-image): optional badge/subtitle unit+integration+e2e

* feat(docker): self-healing entrypoint shim + .dockerignore

* test(draw-image): pytest assert --subtitle omitted when absent

* docs(handoff): optional badge/subtitle + docker entrypoint handoff+ADR

* docs(handoff): set PR number 144

* docs(project-map): fix PR refs 143 to 144

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-30 01:23:25 +03:00 committed by GitHub
parent 56b67d2d5c
commit 0d16b028fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 253 additions and 7 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
app_data/
.git
**/node_modules

View file

@ -31,10 +31,10 @@ export function buildSvg(
let svg = templateSvg let svg = templateSvg
const slotSvgs: string[] = [] const slotSvgs: string[] = []
for (const slot of slots) { for (const slot of slots) {
const bg = renderSlotBackground(slot, brand)
if (bg) slotSvgs.push(bg)
const value = args.slots?.[slot.name] const value = args.slots?.[slot.name]
if (value) { if (value) {
const bg = renderSlotBackground(slot, brand)
if (bg) slotSvgs.push(bg)
const resolved = resolveSlotContent(slot, brand, drawImageDir, value) const resolved = resolveSlotContent(slot, brand, drawImageDir, value)
if (resolved) slotSvgs.push(renderSlotSvg(slot, resolved)) if (resolved) slotSvgs.push(renderSlotSvg(slot, resolved))
} }
@ -47,7 +47,11 @@ export function buildSvg(
svg = svg.replace(/\{\{accent\}\}/g, brand.accent) svg = svg.replace(/\{\{accent\}\}/g, brand.accent)
svg = svg.replace(/\{\{line\}\}/g, brand.line) svg = svg.replace(/\{\{line\}\}/g, brand.line)
svg = svg.replace(/\{\{title\}\}/g, escapeXml(args.title ?? "")) svg = svg.replace(/\{\{title\}\}/g, escapeXml(args.title ?? ""))
svg = svg.replace(/\{\{subtitle\}\}/g, escapeXml(args.subtitle ?? "")) if (args.subtitle) {
svg = svg.replace(/\{\{subtitle\}\}/g, escapeXml(args.subtitle))
} else {
svg = svg.replace(/[^\n]*\{\{subtitle\}\}[^\n]*\n?/g, "")
}
const insertPoint = svg.indexOf("</svg>") const insertPoint = svg.indexOf("</svg>")
if (insertPoint === -1) return svg if (insertPoint === -1) return svg
return svg.slice(0, insertPoint) + slotSvgs.join("\n") + "\n" + svg.slice(insertPoint) return svg.slice(0, insertPoint) + slotSvgs.join("\n") + "\n" + svg.slice(insertPoint)

View file

@ -0,0 +1,46 @@
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = "/tmp/draw-image-optional-e2e"
const TMP_SVG = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg")
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
encoding: "utf-8",
cwd: DRAW_IMAGE_DIR,
})
}
function assertPng(filePath: string) {
expect(existsSync(filePath), `PNG not found: ${filePath}`).toBe(true)
const buf = readFileSync(filePath)
expect(buf.length).toBeGreaterThan(1000)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
}
describe("e2e — optional subtitle and badge via CLI", () => {
test("render with title only → exit 0, valid PNG, svg has no empty badge and no subtitle", () => {
const out = path.join(TMP, "optional.png")
const r = runCli(["render", "cover", "--title", "Test", "--out", out])
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
assertPng(out)
const svg = readFileSync(TMP_SVG, "utf-8")
expect(svg).not.toContain('x="780" y="780"')
expect(svg).not.toContain('y="930"')
expect(svg).not.toContain("{{subtitle}}")
})
})

View file

@ -0,0 +1,61 @@
import { describe, test, expect, beforeAll } from "vitest"
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { spawnSync } from "node:child_process"
import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const TMP = "/tmp/draw-image-optional-integration"
beforeAll(() => {
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
mkdirSync(TMP, { recursive: true })
})
function renderToPng(svg: string, outPath: string): void {
const tmpSvg = path.join(TMP, "input.svg")
writeFileSync(tmpSvg, svg)
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
encoding: "utf-8",
})
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
}
function assertPng(filePath: string) {
expect(existsSync(filePath), `PNG not found: ${filePath}`).toBe(true)
const buf = readFileSync(filePath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
}
describe("integration — optional subtitle and badge", () => {
test("render without subtitle and without badge → valid PNG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = { template: "cover", title: "Bare" }
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "bare.png")
renderToPng(svg, outPath)
assertPng(outPath)
})
test("render with subtitle and badge → valid PNG", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const args = {
template: "cover",
title: "Full",
subtitle: "Sub Text",
slots: { badge: "mic" },
}
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
const outPath = path.join(TMP, "full.png")
renderToPng(svg, outPath)
assertPng(outPath)
})
})

View file

@ -0,0 +1,56 @@
import { describe, test, expect } from "vitest"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { loadBrand } from "../src/config"
import { loadTemplate, buildSvg } from "../src/render"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
const BADGE_RECT = 'x="780" y="780" width="180" height="180"'
const SUBTITLE_Y = 'y="930"'
describe("unit — optional badge background", () => {
test("empty badge slot renders no bg/border rect", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Badge" }, DRAW_IMAGE_DIR)
expect(svg).not.toContain(BADGE_RECT)
expect(svg).not.toContain('x="780" y="780"')
})
test("filled badge slot renders bg/border rect", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "With Badge",
slots: { badge: "mic" },
}, DRAW_IMAGE_DIR)
expect(svg).toContain(BADGE_RECT)
expect(svg).toContain('fill="#121212"')
expect(svg).toContain('stroke="#ccff00"')
})
})
describe("unit — optional subtitle", () => {
test("empty subtitle removes the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Sub" }, DRAW_IMAGE_DIR)
expect(svg).not.toContain(SUBTITLE_Y)
expect(svg).not.toContain("{{subtitle}}")
})
test("set subtitle keeps the subtitle text line", () => {
const brand = loadBrand(DRAW_IMAGE_DIR)
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
const svg = buildSvg(templateSvg, brand, {
template: "cover",
title: "Main",
subtitle: "My Sub",
}, DRAW_IMAGE_DIR)
expect(svg).toContain(SUBTITLE_Y)
expect(svg).toContain("My Sub")
})
})

View file

@ -49,4 +49,7 @@ WORKDIR /root/workspace
EXPOSE 4096 EXPOSE 4096
ENTRYPOINT ["opencode"] COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

7
docker-entrypoint.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
DRAW_IMAGE_DIR="/root/.config/opencode/draw-image"
if [ ! -d "$DRAW_IMAGE_DIR/node_modules/sharp" ]; then
echo "[entrypoint] Installing draw-image dependencies..."
npm ci --prefix "$DRAW_IMAGE_DIR" || echo "[entrypoint] WARN: npm ci failed, draw-image may not work"
fi
exec opencode "$@"

View file

@ -0,0 +1,22 @@
# ADR-061: Optional badge/subtitle + self-healing docker entrypoint (PR#144)
## Статус
Accepted (2026-07-29)
## Контекст
PR#134 (ADR-060) ввёл SVG template renderer для on-brand covers. При тестовом рендере выявлены три проблемы:
1. **Пустой badge рисует квадрат**`renderSlotBackground()` вызывался для каждого слота с bg-спекой ДО проверки контента. Cover без `--slots badge=...` получал пустой `<rect fill="#121212" stroke="#ccff00">` в районе (780, 780).
2. **Subtitle оставляет пустой `<text>`**`{{subtitle}}` заменялся на пустую строку безусловно, но `<text>` элемент оставался в DOM. Большинство cover'ов — title-only.
3. **Fresh clone = broken**`node_modules/` (sharp, lucide-static) gitignored, Dockerfile не устанавливал deps для draw-image. После `docker compose up``Cannot find module 'sharp'`.
## Решение
1. **Slot bg conditional**`renderSlotBackground()` перенесён внутрь `if (value)` блока в `buildSvg()`. bg/border рендерится ТОЛЬКО если слот заполнен контентом.
2. **Subtitle conditional** — если `args.subtitle` falsy, regex `/[^\n]*\{\{subtitle\}\}[^\n]*\n?/g` удаляет всю `<text>` строку с `{{subtitle}}` из шаблона ДО подстановки. При наличии subtitle — обычная подстановка `escapeXml(args.subtitle)`.
3. **Self-healing entrypoint**`docker-entrypoint.sh` (POSIX `#!/bin/sh`) проверяет `node_modules/sharp`, при отсутствии запускает `npm ci --prefix` (continue-on-error: WARN в логах, контейнер стартует), затем `exec opencode "$@"`. Dockerfile `ENTRYPOINT` → entrypoint shim. `.dockerignore` исключает `app_data/`, `.git`, `**/node_modules` из build context.
## Альтернативы
- **Install draw-image deps в Dockerfile (RUN npm ci)** — отвергнуто: node_modules gitignored, COPY не сработает; `npm ci` в build-time требует COPY package.json + package-lock.json из `.opencode/draw-image/`, усложняет Dockerfile. Self-healing entrypoint — одноразовая задержка при первом старте, потом skip.
- **Pre-build hook (postinstall sync-lucide в Dockerfile)** — уже работает через entrypoint: `npm ci` запускает postinstall (`sync-lucide.mjs`), восстанавливает 2007 иконок.
- **Удалить `<text>` subtitle через DOM-парсер (xmldom)** — overkill для одной строки. Regex по строке — достаточно, шаблон контролируемый.
- **`set -euo pipefail` в entrypoint** — отвергнуто: `npm ci` failure (нет сети) должен быть continue-on-error, иначе контейнер не стартует. POSIX `#!/bin/sh` для портативности в slim-образах.

View file

@ -0,0 +1,30 @@
---
pr: 144
title: "fix(draw-image): optional badge/subtitle + self-healing docker entrypoint"
---
## Что сделано
Три фикса для draw-image (PR#134 follow-up):
1. **Пустой badge не рисует квадрат**`renderSlotBackground()` перенесён внутрь `if (value)` блока в `buildSvg()` (`.opencode/draw-image/src/render.ts:33-41`). bg/border `<rect>` рендерится ТОЛЬКО если слот заполнен.
2. **Subtitle опционален** — если `args.subtitle` falsy, вся `<text>` строка с `{{subtitle}}` удаляется из шаблона (`render.ts:50-54`). Без subtitle — чистый вывод (только title).
3. **Docker entrypoint (self-healing)** — новый `docker-entrypoint.sh` (корень): проверяет `node_modules/sharp`, при отсутствии `npm ci` (continue-on-error), `exec opencode "$@"`. Dockerfile `ENTRYPOINT``/usr/local/bin/docker-entrypoint.sh`. Новый `.dockerignore` (`app_data/`, `.git`, `**/node_modules`).
Тесты:
- vitest: `render.optional.test.ts` (unit: empty badge → no rect, empty subtitle → no text, filled badge → rect, set subtitle → text), `render.optional.integration.test.ts` (PNG valid with/without subtitle+badge), `e2e.optional.test.ts` (CLI title-only → exit 0, clean SVG).
- pytest: `test_omits_subtitle_flag_when_not_provided` в `tests/test_draw_image_tool.py`.
- Все 55 vitest + 441 pytest зелёные, ruff чист.
Документация: project-map README обновлён (docker-entrypoint.sh, .dockerignore, новые тест-файлы).
## Почему
PR#134 выявил три проблемы: (1) cover без badge рисовал пустой `<rect>` с `fill=#121212` + `stroke=#ccff00` — визуальный мусор; (2) cover без subtitle оставлял пустой `<text>` элемент в DOM; (3) fresh clone + `docker compose up``Cannot find module 'sharp'` (node_modules gitignored, Dockerfile не устанавливал deps). Большинство cover'ов — title-only, нужен чистый вывод.
## Pending
## Watch out
- `docker-entrypoint.sh` использует `#!/bin/sh` (POSIX), не bash — для максимальной портативности в slim-образах. `set -euo pipefail` НЕ используется намеренно: `npm ci` failure не должен рвать entrypoint (continue-on-error pattern).
- `.dockerignore` исключает `**/node_modules` — это означает что `npm ci` в entrypoint ВСЕГДА будет выполняться при первом старте контейнера (node_modules не копируется в образ). Это by design — self-healing.
- `renderSlotBackground` теперь вызывается только для заполненных слотов — если в будущем понадобится bg для пустого слота-плейсхолдера, придется пересмотреть логику.
- Regex `/[^\n]*\{\{subtitle\}\}[^\n]*\n?/g` удаляет всю строку с `{{subtitle}}` — подразумевает что `{{subtitle}}` на отдельной строке в шаблоне (так и есть в cover.svg).

View file

@ -88,7 +88,10 @@ opencode-config/
│ │ ├── slot.test.ts │ │ ├── slot.test.ts
│ │ ├── e2e.test.ts │ │ ├── e2e.test.ts
│ │ ├── e2e.no-icon.test.ts │ │ ├── e2e.no-icon.test.ts
│ │ └── e2e.bad-input.test.ts │ │ ├── e2e.bad-input.test.ts
│ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144)
│ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144)
│ │ └── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144)
│ ├── scripts/ │ ├── scripts/
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml) │ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml) │ │ ├── check-permissions.py # Permissions validator (permissions-check.yml)
@ -158,8 +161,10 @@ opencode-config/
├── pyproject.toml # Python project (uv, ruff, pytest config) ├── pyproject.toml # Python project (uv, ruff, pytest config)
├── uv.lock # Locked deps for Python project ├── uv.lock # Locked deps for Python project
├── .pre-commit-config.yaml # ruff + UV hooks ├── .pre-commit-config.yaml # ruff + UV hooks
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0; opencode: init: true (tini reaper), healthcheck (curl :4096, 200|401 healthy), limits 8G/4cpu/2048pids — PR#24, PR#51, PR#132 ├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0; opencode: init: true (tini reaper), healthcheck (curl :4096, 200|401 healthy), limits 8G/4cpu/2048pids; command passed through entrypoint shim via "$@" — PR#24, PR#51, PR#132, PR#144
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared + ripgrep (apt fallback для keyword search) (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103, PR#124 ├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared + ripgrep (apt fallback для keyword search); ENTRYPOINT=/usr/local/bin/docker-entrypoint.sh (self-healing: npm ci if sharp missing) (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103, PR#124, PR#144
├── docker-entrypoint.sh # Self-healing entrypoint shim: checks node_modules/sharp, runs npm ci if missing (continue-on-error), exec opencode "$@" — PR#144
├── .dockerignore # Excludes app_data/, .git, **/node_modules from docker build context — PR#144
│ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory) │ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory)
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME) ├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME)
├── app_data/ ├── app_data/

View file

@ -130,3 +130,12 @@ def test_passes_all_args_to_cli():
assert "Sub" in cli_args_str assert "Sub" in cli_args_str
assert "icon=mic" in cli_args_str assert "icon=mic" in cli_args_str
assert TMP_CUSTOM in cli_args_str assert TMP_CUSTOM in cli_args_str
def test_omits_subtitle_flag_when_not_provided():
"""execute does not pass --subtitle when subtitle arg is absent (optional)."""
out = _run_exec({"template": "cover", "title": "Test"}, [OK_RESPONSE])
calls = out["calls"]
assert len(calls) >= 1
cli_args = calls[0]["args"]
assert "--subtitle" not in cli_args, f"unexpected --subtitle flag, got: {cli_args}"