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:
parent
56b67d2d5c
commit
0d16b028fa
11 changed files with 253 additions and 7 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
app_data/
|
||||||
|
.git
|
||||||
|
**/node_modules
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
46
.opencode/draw-image/tests/e2e.optional.test.ts
Normal file
46
.opencode/draw-image/tests/e2e.optional.test.ts
Normal 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}}")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
56
.opencode/draw-image/tests/render.optional.test.ts
Normal file
56
.opencode/draw-image/tests/render.optional.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
7
docker-entrypoint.sh
Executable 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 "$@"
|
||||||
|
|
@ -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-образах.
|
||||||
30
docs/handoff/pr-144-optional-badge-subtitle-entrypoint.md
Normal file
30
docs/handoff/pr-144-optional-badge-subtitle-entrypoint.md
Normal 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).
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue