diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce9a748 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +app_data/ +.git +**/node_modules diff --git a/.opencode/draw-image/src/render.ts b/.opencode/draw-image/src/render.ts index 76efaaa..dc97811 100644 --- a/.opencode/draw-image/src/render.ts +++ b/.opencode/draw-image/src/render.ts @@ -31,10 +31,10 @@ export function buildSvg( let svg = templateSvg const slotSvgs: string[] = [] for (const slot of slots) { - const bg = renderSlotBackground(slot, brand) - if (bg) slotSvgs.push(bg) const value = args.slots?.[slot.name] if (value) { + const bg = renderSlotBackground(slot, brand) + if (bg) slotSvgs.push(bg) const resolved = resolveSlotContent(slot, brand, drawImageDir, value) if (resolved) slotSvgs.push(renderSlotSvg(slot, resolved)) } @@ -47,7 +47,11 @@ export function buildSvg( svg = svg.replace(/\{\{accent\}\}/g, brand.accent) svg = svg.replace(/\{\{line\}\}/g, brand.line) 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("") if (insertPoint === -1) return svg return svg.slice(0, insertPoint) + slotSvgs.join("\n") + "\n" + svg.slice(insertPoint) diff --git a/.opencode/draw-image/tests/e2e.optional.test.ts b/.opencode/draw-image/tests/e2e.optional.test.ts new file mode 100644 index 0000000..44ab24c --- /dev/null +++ b/.opencode/draw-image/tests/e2e.optional.test.ts @@ -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}}") + }) +}) diff --git a/.opencode/draw-image/tests/render.optional.integration.test.ts b/.opencode/draw-image/tests/render.optional.integration.test.ts new file mode 100644 index 0000000..111f1ab --- /dev/null +++ b/.opencode/draw-image/tests/render.optional.integration.test.ts @@ -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) + }) +}) diff --git a/.opencode/draw-image/tests/render.optional.test.ts b/.opencode/draw-image/tests/render.optional.test.ts new file mode 100644 index 0000000..e895a6c --- /dev/null +++ b/.opencode/draw-image/tests/render.optional.test.ts @@ -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") + }) +}) diff --git a/Dockerfile b/Dockerfile index b34d227..7d1e77a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,4 +49,7 @@ WORKDIR /root/workspace EXPOSE 4096 -ENTRYPOINT ["opencode"] \ No newline at end of file +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"] \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..af66da7 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" \ No newline at end of file diff --git a/docs/decisions/061-pr-144-optional-badge-subtitle-entrypoint.md b/docs/decisions/061-pr-144-optional-badge-subtitle-entrypoint.md new file mode 100644 index 0000000..7de828b --- /dev/null +++ b/docs/decisions/061-pr-144-optional-badge-subtitle-entrypoint.md @@ -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=...` получал пустой `` в районе (780, 780). +2. **Subtitle оставляет пустой ``** — `{{subtitle}}` заменялся на пустую строку безусловно, но `` элемент оставался в 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` удаляет всю `` строку с `{{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 иконок. +- **Удалить `` subtitle через DOM-парсер (xmldom)** — overkill для одной строки. Regex по строке — достаточно, шаблон контролируемый. +- **`set -euo pipefail` в entrypoint** — отвергнуто: `npm ci` failure (нет сети) должен быть continue-on-error, иначе контейнер не стартует. POSIX `#!/bin/sh` для портативности в slim-образах. \ No newline at end of file diff --git a/docs/handoff/pr-144-optional-badge-subtitle-entrypoint.md b/docs/handoff/pr-144-optional-badge-subtitle-entrypoint.md new file mode 100644 index 0000000..5868b1b --- /dev/null +++ b/docs/handoff/pr-144-optional-badge-subtitle-entrypoint.md @@ -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 `` рендерится ТОЛЬКО если слот заполнен. +2. **Subtitle опционален** — если `args.subtitle` falsy, вся `` строка с `{{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 рисовал пустой `` с `fill=#121212` + `stroke=#ccff00` — визуальный мусор; (2) cover без subtitle оставлял пустой `` элемент в 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). \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 6dcdedd..097c960 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -88,7 +88,10 @@ opencode-config/ │ │ ├── slot.test.ts │ │ ├── e2e.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/ │ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml) │ │ ├── check-permissions.py # Permissions validator (permissions-check.yml) @@ -158,8 +161,10 @@ opencode-config/ ├── pyproject.toml # Python project (uv, ruff, pytest config) ├── uv.lock # Locked deps for Python project ├── .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 -├── 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 +├── 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); 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) ├── .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/ diff --git a/tests/test_draw_image_tool.py b/tests/test_draw_image_tool.py index 210c094..6217d4e 100644 --- a/tests/test_draw_image_tool.py +++ b/tests/test_draw_image_tool.py @@ -130,3 +130,12 @@ def test_passes_all_args_to_cli(): assert "Sub" in cli_args_str assert "icon=mic" 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}"