From 2c13a89ffe50a5182c117790a48cec373bfd7d17 Mon Sep 17 00:00:00 2001 From: yann ARMAND Date: Mon, 13 Jul 2026 08:35:43 -0700 Subject: [PATCH 1/6] docs: add koffan grocery-list stacklet design spec --- .../2026-07-13-koffan-stacklet-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md diff --git a/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md b/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md new file mode 100644 index 0000000..4d2a557 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md @@ -0,0 +1,187 @@ +# koffan stacklet - design + +Date: 2026-07-13 +Status: approved, ready for implementation + +## Purpose + +Add a `koffan` stacklet that runs [Koffan](https://github.com/PanSalut/Koffan), +a lightweight shared shopping-list web app, as a family grocery list. Koffan is +a single Go binary with SQLite storage, real-time WebSocket sync, and a PWA +front end. One shared password logs the whole family in. + +## Scope + +In scope: + +- A single-container docker stacklet following the framework conventions. +- Auto-generated shared login password surfaced to the family. +- Caddy route for domain mode. +- Language and timezone inherited from the stack config. + +Out of scope (v1): + +- Backups. Koffan stores a single mutable SQLite file (`/data/shopping.db`). + The only implemented backup mechanism is the append-only `[[backup.archive]]` + (files added, never modified, with a ransomware canary), which does not fit a + database that is rewritten in place. This mirrors the current state of the + `photos` stacklet, whose Postgres data is likewise not yet backed up. A + `[[backup.snapshot]]` primitive is planned framework-wide and would cover + Koffan later. +- Per-user accounts. Koffan has one shared password by design; we do not layer + famstack's per-user model on top. +- Bots, CLI plugins, and lifecycle hooks. None are needed. + +## Architecture + +Single service, no database sidecar (SQLite is embedded). + +``` +stacklets/koffan/ + stacklet.toml # manifest + docker-compose.yml # one service: stack-koffan + caddy.snippet # koffan.{domain} -> koffan:8080 +``` + +### Service + +- Container name: `stack-koffan` +- Image: `ghcr.io/pansalut/koffan` (compose tag `:latest`; Watchtower manages + updates) +- Network: `stack` (external) +- Port: host `42080` -> container `8080`, bound as + `${PORT_BIND_IP:-127.0.0.1}:42080:8080` +- Volume: `${KOFFAN_DATA_DIR}:/data` (SQLite lives at `/data/shopping.db`) +- `restart: unless-stopped` +- Watchtower label: `com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}` + +Port `42080` is the next free slot in the 42xxx convention (42010-42070 are in +use). + +## Manifest (`stacklet.toml`) + +```toml +id = "koffan" +name = "Koffan" +description = "Shared family shopping list (Koffan)" +version = "0.1.0" +category = "productivity" +port = 42080 + +hints = [ + "Open {url}", + "Log in with the shared password: {koffan__APP_PASSWORD}", + "Install it as an app from your phone browser (Add to Home Screen)", +] + +[upstream] +image = "ghcr.io/pansalut/koffan" +channel = "patch" + +[env] +generate = ["APP_PASSWORD"] + +[env.defaults] +KOFFAN_DATA_DIR = "{data_dir}/koffan" +TZ = "{timezone}" +DB_PATH = "/data/shopping.db" +DEFAULT_LANG = "{language}" +# development keeps cookies working over plain HTTP in port mode (the famstack +# default). production would force the Secure cookie flag and break port-mode +# login. Domain mode still works fine under development. +APP_ENV = "development" +DISABLE_AUTH = "false" + +[health] +url = "http://localhost:42080" +expect = "200" +``` + +Notes: + +- `generate = ["APP_PASSWORD"]` makes the framework mint a random password once, + store it in `.stack/secrets.toml` as `koffan__APP_PASSWORD`, inject it into the + container env, and expose it to hints as `{koffan__APP_PASSWORD}`. It survives + across `stack up` runs and is regenerated only after `stack destroy`. +- `DEFAULT_LANG` uses the `{language}` template var. Koffan supports + pl/en/de/es/fr/pt/uk/no/lt/el/sk/ru and falls back to `en` for anything else. + +## Data flow + +1. `stack up koffan` renders `.env` from `[env.defaults]`, generating + `APP_PASSWORD` on first run. +2. Docker Compose starts `stack-koffan`, mounting `{data_dir}/koffan` at `/data`. +3. Koffan serves the PWA on `:8080`; the family reaches it at + `http://:42080` (port mode) or `https://koffan.` (domain mode). +4. Family members log in once per device with the shared password; the cookie + keeps them signed in. + +## Compose (`docker-compose.yml`) + +```yaml +name: stack-koffan + +services: + stack-koffan: + container_name: stack-koffan + image: ghcr.io/pansalut/koffan:latest + labels: + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}" + networks: + - stack + volumes: + - ${KOFFAN_DATA_DIR}:/data + environment: + APP_ENV: ${APP_ENV:-development} + APP_PASSWORD: ${APP_PASSWORD} + DISABLE_AUTH: ${DISABLE_AUTH:-false} + DB_PATH: ${DB_PATH:-/data/shopping.db} + DEFAULT_LANG: ${DEFAULT_LANG:-en} + TZ: ${TZ} + ports: + - "${PORT_BIND_IP:-127.0.0.1}:42080:8080" + restart: unless-stopped + +networks: + stack: + external: true +``` + +## Caddy route (`caddy.snippet`) + +``` +koffan.{$FAMSTACK_DOMAIN} { + reverse_proxy koffan:8080 +} +``` + +Koffan uses a WebSocket for real-time sync; Caddy's `reverse_proxy` upgrades +WebSocket connections automatically, so no extra directive is required. + +## Error handling + +- Health check hits `http://localhost:42080` and expects `200`; a container that + fails to start shows as `failing`/`degraded` in `stack list` with the standard + framework hints. +- No custom hooks means no custom failure paths; the framework's up/down/destroy + lifecycle applies unchanged. + +## Testing + +- Add a per-stacklet unit test under `tests/stacklets/` that asserts the manifest + parses, declares port 42080, generates `APP_PASSWORD`, and renders the expected + env (mirroring existing per-stacklet tests). No Docker required. +- Follow existing patterns in `tests/stacklets/`; do not invent a new test shape. +- A Docker integration test is not warranted for v1: the stacklet crosses no + container boundary beyond a plain reverse proxy and adds no bespoke logic. + +## Success criteria + +1. `stack up koffan` on a fresh instance pulls the image, generates a password, + starts the container, and passes the health check. +2. The post-up hints print the URL and the shared password. +3. The list persists across `stack down` / `stack up` (SQLite file on the bind + mount). +4. In domain mode, `koffan.` reverse-proxies including WebSocket sync. +5. The new per-stacklet test passes under `uv run --extra test pytest + tests/stacklets/`. From 38dc6892c02ecaa79c8027b4fc8d20be7769c1e2 Mon Sep 17 00:00:00 2001 From: yann ARMAND Date: Mon, 13 Jul 2026 08:45:08 -0700 Subject: [PATCH 2/6] docs: add koffan stacklet implementation plan --- .../plans/2026-07-13-koffan-stacklet.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-koffan-stacklet.md diff --git a/docs/superpowers/plans/2026-07-13-koffan-stacklet.md b/docs/superpowers/plans/2026-07-13-koffan-stacklet.md new file mode 100644 index 0000000..3e0ab02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-koffan-stacklet.md @@ -0,0 +1,282 @@ +# koffan Stacklet Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `koffan` stacklet that runs the Koffan shared shopping-list web app as a family grocery list. + +**Architecture:** A single-container docker stacklet (image `ghcr.io/pansalut/koffan`) on the `stack` network, exposing port 42080 -> container 8080, with SQLite persisted on a bind mount. A shared login password is auto-generated by the framework and shown to the family via post-up hints. A Caddy snippet provides the domain-mode route. + +**Tech Stack:** Python 3.11 stdlib CLI framework, TOML manifests, Docker Compose, Caddy, pytest. + +## Global Constraints + +- Apple Silicon macOS only. +- Product name is always lowercase "famstack"; inside the codebase say "stack"/"stacklet". +- `id` == directory name: `koffan` (lowercase, no hyphens). +- Container name `stack-koffan`; every container joins the external `stack` network. +- Ports declared in `stacklet.toml` (`port = 42080`), bound in compose as `${PORT_BIND_IP:-...}:42080:8080`. +- Bind mounts only, no named Docker volumes. +- `restart: unless-stopped`; Watchtower label on every container. +- No `build:` in compose (no `build = true` in the manifest). +- `.env` is a derived artifact: never edit/commit/read it. +- The CLI is stdlib-only; tests get deps via the `test` extra. +- Test runner: `uv run --extra test pytest`. +- Commit prefixes: `feat:`, `fix:`, `docs:`, etc. No `Co-Authored-By:` trailers. Never commit to `main`; feature branches only. Never `git push` without explicit approval. +- No em dashes in user-facing text. + +--- + +### Task 1: Create the koffan stacklet (manifest, compose, caddy) with a validation test + +**Files:** +- Create: `stacklets/koffan/stacklet.toml` +- Create: `stacklets/koffan/docker-compose.yml` +- Create: `stacklets/koffan/caddy.snippet` +- Test: `tests/stacklets/test_koffan_manifest.py` + +**Interfaces:** +- Consumes: `stack.Stack(root, data, instance_dir=..., output=...)`; `Stack.discover()` returns a list of dicts each with keys `id`, `name`, `description`, `category`, `port`, `manifest`; `Stack.env(stacklet_id)` returns the rendered env dict and generates any `[env].generate` secrets into `instance_dir/.stack/secrets.toml`. +- Produces: a discoverable stacklet with `id == "koffan"`, `port == 42080`, that renders env keys `KOFFAN_DATA_DIR`, `TZ`, `DB_PATH`, `DEFAULT_LANG`, `APP_ENV`, `DISABLE_AUTH`, and a generated non-empty `APP_PASSWORD`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/stacklets/test_koffan_manifest.py`: + +```python +"""koffan stacklet: manifest discovery and env rendering. + +Roots the Stack at the real repo (so it discovers the real koffan +stacklet) but points instance_dir/data at tmp_path so generated +secrets never touch the working tree. +""" + +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_LIB_DIR = REPO_ROOT / "lib" +if str(_LIB_DIR) not in sys.path: + sys.path.insert(0, str(_LIB_DIR)) + + +@pytest.fixture +def koffan_stack(tmp_path): + """A Stack rooted at the real repo, with an isolated instance dir.""" + (tmp_path / "stack.toml").write_text( + '[core]\n' + 'domain = ""\n' + f'data_dir = "{tmp_path / "data"}"\n' + 'timezone = "Europe/Berlin"\n' + 'language = "en"\n' + '\n' + '[ai]\n' + 'default = "test-model"\n' + 'language = "en"\n' + '\n' + '[messages]\n' + 'server_name = "testserver"\n' + ) + (tmp_path / "users.toml").write_text( + '[[users]]\n' + 'name = "Test Admin"\n' + 'email = "admin@test.local"\n' + 'password = "testpass"\n' + 'role = "admin"\n' + ) + from stack import Stack + return Stack(root=REPO_ROOT, data=tmp_path / "data", instance_dir=tmp_path) + + +def test_manifest_discovered(koffan_stack): + by_id = {s["id"]: s for s in koffan_stack.discover()} + assert "koffan" in by_id + koffan = by_id["koffan"] + assert koffan["port"] == 42080 + assert koffan["category"] == "productivity" + + +def test_env_rendering(koffan_stack): + env = koffan_stack.env("koffan") + assert env["KOFFAN_DATA_DIR"].endswith("/koffan") + assert env["DB_PATH"] == "/data/shopping.db" + assert env["DEFAULT_LANG"] == "en" + assert env["APP_ENV"] == "development" + assert env["DISABLE_AUTH"] == "false" + assert env["TZ"] == "Europe/Berlin" + # Shared password is auto-generated and non-empty. + assert env["APP_PASSWORD"] +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run --extra test pytest tests/stacklets/test_koffan_manifest.py -v` +Expected: FAIL — `test_manifest_discovered` asserts `"koffan" in by_id` and koffan does not exist yet (assertion error). + +- [ ] **Step 3: Create the manifest** + +Create `stacklets/koffan/stacklet.toml`: + +```toml +# stacklet.toml — koffan stacklet (shared shopping list) +# +# Koffan is an ultra-light shopping-list web app for couples and families. +# One shared password logs everyone in; the list syncs across phones and +# desktops in real time over a WebSocket. Data lives in a single SQLite +# file on a bind mount. + +id = "koffan" +name = "Koffan" +description = "Shared family shopping list (Koffan)" +version = "0.1.0" +category = "productivity" +port = 42080 + +hints = [ + "Open {url}", + "Log in with the shared password: {koffan__APP_PASSWORD}", + "Install it as an app from your phone browser (Add to Home Screen)", +] + +[upstream] +image = "ghcr.io/pansalut/koffan" +channel = "patch" + +# The shared login password is generated once and stored in +# .stack/secrets.toml as koffan__APP_PASSWORD. It survives across +# 'stack up' runs and is regenerated only after 'stack destroy'. +[env] +generate = ["APP_PASSWORD"] + +[env.defaults] +KOFFAN_DATA_DIR = "{data_dir}/koffan" +TZ = "{timezone}" +DB_PATH = "/data/shopping.db" +DEFAULT_LANG = "{language}" +# 'development' keeps the login cookie working over plain HTTP in port +# mode (the famstack default). 'production' would force the Secure cookie +# flag and break port-mode login. Domain mode (HTTPS) works fine here too. +APP_ENV = "development" +DISABLE_AUTH = "false" + +[health] +url = "http://localhost:42080" +expect = "200" +``` + +- [ ] **Step 4: Run the test to verify env rendering passes** + +Run: `uv run --extra test pytest tests/stacklets/test_koffan_manifest.py -v` +Expected: PASS — both `test_manifest_discovered` and `test_env_rendering` pass. + +- [ ] **Step 5: Create the compose file** + +Create `stacklets/koffan/docker-compose.yml`: + +```yaml +# stacklets/koffan/docker-compose.yml — Koffan shopping list +# +# Single Go binary with embedded SQLite. No database sidecar. The list +# is stored at /data/shopping.db on the KOFFAN_DATA_DIR bind mount. + +name: stack-koffan + +services: + stack-koffan: + container_name: stack-koffan + image: ghcr.io/pansalut/koffan:latest + labels: + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}" + networks: + - stack + volumes: + - ${KOFFAN_DATA_DIR}:/data + environment: + APP_ENV: ${APP_ENV:-development} + APP_PASSWORD: ${APP_PASSWORD} + DISABLE_AUTH: ${DISABLE_AUTH:-false} + DB_PATH: ${DB_PATH:-/data/shopping.db} + DEFAULT_LANG: ${DEFAULT_LANG:-en} + TZ: ${TZ} + ports: + - "${PORT_BIND_IP:-127.0.0.1}:42080:8080" + restart: unless-stopped + +networks: + stack: + external: true +``` + +- [ ] **Step 6: Create the caddy snippet** + +Create `stacklets/koffan/caddy.snippet`: + +``` +# stacklets/koffan/caddy.snippet +# +# Assembled into the Caddyfile by 'stack up' in domain mode. Caddy runs +# in Docker on the stack network, so the backend is referenced by +# container name. reverse_proxy upgrades the WebSocket automatically, so +# real-time sync works without extra directives. + +koffan.{$FAMSTACK_DOMAIN} { + reverse_proxy koffan:8080 +} +``` + +- [ ] **Step 7: Validate the compose file parses** + +Run: `docker compose -f stacklets/koffan/docker-compose.yml config -q && echo OK` +Expected: prints `OK` (unset env vars are fine here; `config -q` only checks structure). If `docker` is unavailable, skip this step. + +- [ ] **Step 8: Run the full stacklet test file again** + +Run: `uv run --extra test pytest tests/stacklets/test_koffan_manifest.py -v` +Expected: PASS — both tests still pass. + +- [ ] **Step 9: Commit** + +```bash +git add stacklets/koffan/stacklet.toml \ + stacklets/koffan/docker-compose.yml \ + stacklets/koffan/caddy.snippet \ + tests/stacklets/test_koffan_manifest.py +git commit -m "feat: add koffan shared shopping-list stacklet" +``` + +--- + +### Task 2: Run the fast test suite to confirm no regressions + +**Files:** +- None (verification only). + +**Interfaces:** +- Consumes: the committed koffan stacklet from Task 1. +- Produces: nothing; a green fast suite gate. + +- [ ] **Step 1: Run the framework and stacklet suites** + +Run: `uv run --extra test pytest tests/framework/ tests/stacklets/ -q` +Expected: PASS — all tests green, including the two new koffan tests. Discovery and env-rendering tests that iterate all stacklets must still pass with koffan present. + +- [ ] **Step 2: If anything fails** + +Read the failure. If a framework test that enumerates all stacklets fails because of a koffan field (e.g. an unexpected `category` value or a missing required manifest key), fix `stacklets/koffan/stacklet.toml` to match the convention the test enforces, then re-run Step 1. Do not weaken the failing test. + +- [ ] **Step 3: Commit any fix (only if Step 2 changed a file)** + +```bash +git add stacklets/koffan/stacklet.toml +git commit -m "fix: align koffan manifest with framework conventions" +``` + +--- + +## Notes for the implementer + +- **No hooks, no CLI plugins, no bot.** The stacklet is pure declaration plus one compose service. Do not add lifecycle hooks; the framework's up/down/destroy handles everything. +- **Backups are intentionally out of scope.** Do not add a `[[backup.archive]]` block. Koffan's SQLite file is rewritten in place and does not fit the append-only archive model (which has a ransomware canary and `min_files` check). This matches the current `photos` stacklet, whose Postgres data is likewise not yet backed up. +- **Do not run integration/Docker tests without asking the user first** — they collide with the user's running instance. The Task 1 Step 7 `docker compose config` check is a static parse, not a container start, so it is safe. +- **Reference for patterns:** `stacklets/chatai/` (single-service compose + caddy) and `stacklets/photos/stacklet.toml` (`[env].generate` + `[env.defaults]`). From c6be7fb014fa3f510d0ae9083bea3eef726723cf Mon Sep 17 00:00:00 2001 From: yann ARMAND Date: Mon, 13 Jul 2026 08:49:07 -0700 Subject: [PATCH 3/6] feat: add koffan shared shopping-list stacklet --- stacklets/koffan/caddy.snippet | 10 ++++ stacklets/koffan/docker-compose.yml | 31 ++++++++++++ stacklets/koffan/stacklet.toml | 44 +++++++++++++++++ tests/stacklets/test_koffan_manifest.py | 64 +++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 stacklets/koffan/caddy.snippet create mode 100644 stacklets/koffan/docker-compose.yml create mode 100644 stacklets/koffan/stacklet.toml create mode 100644 tests/stacklets/test_koffan_manifest.py diff --git a/stacklets/koffan/caddy.snippet b/stacklets/koffan/caddy.snippet new file mode 100644 index 0000000..e2993ef --- /dev/null +++ b/stacklets/koffan/caddy.snippet @@ -0,0 +1,10 @@ +# stacklets/koffan/caddy.snippet +# +# Assembled into the Caddyfile by 'stack up' in domain mode. Caddy runs +# in Docker on the stack network, so the backend is referenced by +# container name. reverse_proxy upgrades the WebSocket automatically, so +# real-time sync works without extra directives. + +koffan.{$FAMSTACK_DOMAIN} { + reverse_proxy stack-koffan:8080 +} diff --git a/stacklets/koffan/docker-compose.yml b/stacklets/koffan/docker-compose.yml new file mode 100644 index 0000000..8a20464 --- /dev/null +++ b/stacklets/koffan/docker-compose.yml @@ -0,0 +1,31 @@ +# stacklets/koffan/docker-compose.yml — Koffan shopping list +# +# Single Go binary with embedded SQLite. No database sidecar. The list +# is stored at /data/shopping.db on the KOFFAN_DATA_DIR bind mount. + +name: stack-koffan + +services: + stack-koffan: + container_name: stack-koffan + image: ghcr.io/pansalut/koffan:latest + labels: + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}" + networks: + - stack + volumes: + - ${KOFFAN_DATA_DIR}:/data + environment: + APP_ENV: ${APP_ENV:-development} + APP_PASSWORD: ${APP_PASSWORD} + DISABLE_AUTH: ${DISABLE_AUTH:-false} + DB_PATH: ${DB_PATH:-/data/shopping.db} + DEFAULT_LANG: ${DEFAULT_LANG:-en} + TZ: ${TZ} + ports: + - "${PORT_BIND_IP:-127.0.0.1}:42090:8080" + restart: unless-stopped + +networks: + stack: + external: true diff --git a/stacklets/koffan/stacklet.toml b/stacklets/koffan/stacklet.toml new file mode 100644 index 0000000..da74767 --- /dev/null +++ b/stacklets/koffan/stacklet.toml @@ -0,0 +1,44 @@ +# stacklet.toml — koffan stacklet (shared shopping list) +# +# Koffan is an ultra-light shopping-list web app for couples and families. +# One shared password logs everyone in; the list syncs across phones and +# desktops in real time over a WebSocket. Data lives in a single SQLite +# file on a bind mount. + +id = "koffan" +name = "Koffan" +description = "Shared family shopping list (Koffan)" +version = "0.1.0" +category = "productivity" +port = 42090 + +hints = [ + "Open {url}", + "Log in with the shared password: {koffan__APP_PASSWORD}", + "Install it as an app from your phone browser (Add to Home Screen)", +] + +[upstream] +image = "ghcr.io/pansalut/koffan" +channel = "patch" + +# The shared login password is generated once and stored in +# .stack/secrets.toml as koffan__APP_PASSWORD. It survives across +# 'stack up' runs and is regenerated only after 'stack destroy'. +[env] +generate = ["APP_PASSWORD"] + +[env.defaults] +KOFFAN_DATA_DIR = "{data_dir}/koffan" +TZ = "{timezone}" +DB_PATH = "/data/shopping.db" +DEFAULT_LANG = "{language}" +# 'development' keeps the login cookie working over plain HTTP in port +# mode (the famstack default). 'production' would force the Secure cookie +# flag and break port-mode login. Domain mode (HTTPS) works fine here too. +APP_ENV = "development" +DISABLE_AUTH = "false" + +[health] +url = "http://localhost:42090" +expect = "200" diff --git a/tests/stacklets/test_koffan_manifest.py b/tests/stacklets/test_koffan_manifest.py new file mode 100644 index 0000000..ac50ca9 --- /dev/null +++ b/tests/stacklets/test_koffan_manifest.py @@ -0,0 +1,64 @@ +"""koffan stacklet: manifest discovery and env rendering. + +Roots the Stack at the real repo (so it discovers the real koffan +stacklet) but points instance_dir/data at tmp_path so generated +secrets never touch the working tree. +""" + +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_LIB_DIR = REPO_ROOT / "lib" +if str(_LIB_DIR) not in sys.path: + sys.path.insert(0, str(_LIB_DIR)) + + +@pytest.fixture +def koffan_stack(tmp_path): + """A Stack rooted at the real repo, with an isolated instance dir.""" + (tmp_path / "stack.toml").write_text( + '[core]\n' + 'domain = ""\n' + f'data_dir = "{tmp_path / "data"}"\n' + 'timezone = "Europe/Berlin"\n' + 'language = "en"\n' + '\n' + '[ai]\n' + 'default = "test-model"\n' + 'language = "en"\n' + '\n' + '[messages]\n' + 'server_name = "testserver"\n' + ) + (tmp_path / "users.toml").write_text( + '[[users]]\n' + 'name = "Test Admin"\n' + 'email = "admin@test.local"\n' + 'password = "testpass"\n' + 'role = "admin"\n' + ) + from stack import Stack + return Stack(root=REPO_ROOT, data=tmp_path / "data", instance_dir=tmp_path) + + +def test_manifest_discovered(koffan_stack): + by_id = {s["id"]: s for s in koffan_stack.discover()} + assert "koffan" in by_id + koffan = by_id["koffan"] + assert koffan["port"] == 42090 + assert koffan["category"] == "productivity" + + +def test_env_rendering(koffan_stack): + env = koffan_stack.env("koffan") + assert env["KOFFAN_DATA_DIR"].endswith("/koffan") + assert env["DB_PATH"] == "/data/shopping.db" + assert env["DEFAULT_LANG"] == "en" + assert env["APP_ENV"] == "development" + assert env["DISABLE_AUTH"] == "false" + assert env["TZ"] == "Europe/Berlin" + # Shared password is auto-generated and non-empty. + assert env["APP_PASSWORD"] From f89ba31a2466a19bf59f72c91623782fc1dd7b9f Mon Sep 17 00:00:00 2001 From: yann ARMAND Date: Mon, 13 Jul 2026 08:54:35 -0700 Subject: [PATCH 4/6] docs: correct koffan port to 42090 and caddy backend to container name --- .../plans/2026-07-13-koffan-stacklet.md | 16 ++++++------ .../2026-07-13-koffan-stacklet-design.md | 25 ++++++++++--------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/plans/2026-07-13-koffan-stacklet.md b/docs/superpowers/plans/2026-07-13-koffan-stacklet.md index 3e0ab02..e9ceba0 100644 --- a/docs/superpowers/plans/2026-07-13-koffan-stacklet.md +++ b/docs/superpowers/plans/2026-07-13-koffan-stacklet.md @@ -4,7 +4,7 @@ **Goal:** Add a `koffan` stacklet that runs the Koffan shared shopping-list web app as a family grocery list. -**Architecture:** A single-container docker stacklet (image `ghcr.io/pansalut/koffan`) on the `stack` network, exposing port 42080 -> container 8080, with SQLite persisted on a bind mount. A shared login password is auto-generated by the framework and shown to the family via post-up hints. A Caddy snippet provides the domain-mode route. +**Architecture:** A single-container docker stacklet (image `ghcr.io/pansalut/koffan`) on the `stack` network, exposing port 42090 -> container 8080, with SQLite persisted on a bind mount. A shared login password is auto-generated by the framework and shown to the family via post-up hints. A Caddy snippet provides the domain-mode route. **Tech Stack:** Python 3.11 stdlib CLI framework, TOML manifests, Docker Compose, Caddy, pytest. @@ -14,7 +14,7 @@ - Product name is always lowercase "famstack"; inside the codebase say "stack"/"stacklet". - `id` == directory name: `koffan` (lowercase, no hyphens). - Container name `stack-koffan`; every container joins the external `stack` network. -- Ports declared in `stacklet.toml` (`port = 42080`), bound in compose as `${PORT_BIND_IP:-...}:42080:8080`. +- Ports declared in `stacklet.toml` (`port = 42090`), bound in compose as `${PORT_BIND_IP:-...}:42090:8080`. - Bind mounts only, no named Docker volumes. - `restart: unless-stopped`; Watchtower label on every container. - No `build:` in compose (no `build = true` in the manifest). @@ -36,7 +36,7 @@ **Interfaces:** - Consumes: `stack.Stack(root, data, instance_dir=..., output=...)`; `Stack.discover()` returns a list of dicts each with keys `id`, `name`, `description`, `category`, `port`, `manifest`; `Stack.env(stacklet_id)` returns the rendered env dict and generates any `[env].generate` secrets into `instance_dir/.stack/secrets.toml`. -- Produces: a discoverable stacklet with `id == "koffan"`, `port == 42080`, that renders env keys `KOFFAN_DATA_DIR`, `TZ`, `DB_PATH`, `DEFAULT_LANG`, `APP_ENV`, `DISABLE_AUTH`, and a generated non-empty `APP_PASSWORD`. +- Produces: a discoverable stacklet with `id == "koffan"`, `port == 42090`, that renders env keys `KOFFAN_DATA_DIR`, `TZ`, `DB_PATH`, `DEFAULT_LANG`, `APP_ENV`, `DISABLE_AUTH`, and a generated non-empty `APP_PASSWORD`. - [ ] **Step 1: Write the failing test** @@ -93,7 +93,7 @@ def test_manifest_discovered(koffan_stack): by_id = {s["id"]: s for s in koffan_stack.discover()} assert "koffan" in by_id koffan = by_id["koffan"] - assert koffan["port"] == 42080 + assert koffan["port"] == 42090 assert koffan["category"] == "productivity" @@ -131,7 +131,7 @@ name = "Koffan" description = "Shared family shopping list (Koffan)" version = "0.1.0" category = "productivity" -port = 42080 +port = 42090 hints = [ "Open {url}", @@ -161,7 +161,7 @@ APP_ENV = "development" DISABLE_AUTH = "false" [health] -url = "http://localhost:42080" +url = "http://localhost:42090" expect = "200" ``` @@ -200,7 +200,7 @@ services: DEFAULT_LANG: ${DEFAULT_LANG:-en} TZ: ${TZ} ports: - - "${PORT_BIND_IP:-127.0.0.1}:42080:8080" + - "${PORT_BIND_IP:-127.0.0.1}:42090:8080" restart: unless-stopped networks: @@ -221,7 +221,7 @@ Create `stacklets/koffan/caddy.snippet`: # real-time sync works without extra directives. koffan.{$FAMSTACK_DOMAIN} { - reverse_proxy koffan:8080 + reverse_proxy stack-koffan:8080 } ``` diff --git a/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md b/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md index 4d2a557..ead515a 100644 --- a/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md +++ b/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md @@ -40,7 +40,7 @@ Single service, no database sidecar (SQLite is embedded). stacklets/koffan/ stacklet.toml # manifest docker-compose.yml # one service: stack-koffan - caddy.snippet # koffan.{domain} -> koffan:8080 + caddy.snippet # koffan.{domain} -> stack-koffan:8080 ``` ### Service @@ -49,14 +49,15 @@ stacklets/koffan/ - Image: `ghcr.io/pansalut/koffan` (compose tag `:latest`; Watchtower manages updates) - Network: `stack` (external) -- Port: host `42080` -> container `8080`, bound as - `${PORT_BIND_IP:-127.0.0.1}:42080:8080` +- Port: host `42090` -> container `8080`, bound as + `${PORT_BIND_IP:-127.0.0.1}:42090:8080` - Volume: `${KOFFAN_DATA_DIR}:/data` (SQLite lives at `/data/shopping.db`) - `restart: unless-stopped` - Watchtower label: `com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}` -Port `42080` is the next free slot in the 42xxx convention (42010-42070 are in -use). +Port `42090` is the next free slot in the 42xxx convention. Note that 42080 is +already taken by the infra stacklet's AdGuard admin panel (declared in its +`[ports]` table as `dns_admin`, not a top-level `port =`), so koffan skips it. ## Manifest (`stacklet.toml`) @@ -66,7 +67,7 @@ name = "Koffan" description = "Shared family shopping list (Koffan)" version = "0.1.0" category = "productivity" -port = 42080 +port = 42090 hints = [ "Open {url}", @@ -93,7 +94,7 @@ APP_ENV = "development" DISABLE_AUTH = "false" [health] -url = "http://localhost:42080" +url = "http://localhost:42090" expect = "200" ``` @@ -112,7 +113,7 @@ Notes: `APP_PASSWORD` on first run. 2. Docker Compose starts `stack-koffan`, mounting `{data_dir}/koffan` at `/data`. 3. Koffan serves the PWA on `:8080`; the family reaches it at - `http://:42080` (port mode) or `https://koffan.` (domain mode). + `http://:42090` (port mode) or `https://koffan.` (domain mode). 4. Family members log in once per device with the shared password; the cookie keeps them signed in. @@ -139,7 +140,7 @@ services: DEFAULT_LANG: ${DEFAULT_LANG:-en} TZ: ${TZ} ports: - - "${PORT_BIND_IP:-127.0.0.1}:42080:8080" + - "${PORT_BIND_IP:-127.0.0.1}:42090:8080" restart: unless-stopped networks: @@ -151,7 +152,7 @@ networks: ``` koffan.{$FAMSTACK_DOMAIN} { - reverse_proxy koffan:8080 + reverse_proxy stack-koffan:8080 } ``` @@ -160,7 +161,7 @@ WebSocket connections automatically, so no extra directive is required. ## Error handling -- Health check hits `http://localhost:42080` and expects `200`; a container that +- Health check hits `http://localhost:42090` and expects `200`; a container that fails to start shows as `failing`/`degraded` in `stack list` with the standard framework hints. - No custom hooks means no custom failure paths; the framework's up/down/destroy @@ -169,7 +170,7 @@ WebSocket connections automatically, so no extra directive is required. ## Testing - Add a per-stacklet unit test under `tests/stacklets/` that asserts the manifest - parses, declares port 42080, generates `APP_PASSWORD`, and renders the expected + parses, declares port 42090, generates `APP_PASSWORD`, and renders the expected env (mirroring existing per-stacklet tests). No Docker required. - Follow existing patterns in `tests/stacklets/`; do not invent a new test shape. - A Docker integration test is not warranted for v1: the stacklet crosses no From c42b99f483d0bae592ee79252712ebf14aef0f73 Mon Sep 17 00:00:00 2001 From: yann ARMAND Date: Mon, 13 Jul 2026 10:42:09 -0700 Subject: [PATCH 5/6] feat: rename koffan display name to ShoppingList --- stacklets/koffan/stacklet.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacklets/koffan/stacklet.toml b/stacklets/koffan/stacklet.toml index da74767..e14f674 100644 --- a/stacklets/koffan/stacklet.toml +++ b/stacklets/koffan/stacklet.toml @@ -6,7 +6,7 @@ # file on a bind mount. id = "koffan" -name = "Koffan" +name = "ShoppingList" description = "Shared family shopping list (Koffan)" version = "0.1.0" category = "productivity" From 2c4ad9c5e65d6ad2425a07e9b63e409aa089128e Mon Sep 17 00:00:00 2001 From: yann ARMAND Date: Mon, 13 Jul 2026 15:39:30 -0700 Subject: [PATCH 6/6] delete superpowers artifacts --- .../plans/2026-07-13-koffan-stacklet.md | 282 ------------------ .../2026-07-13-koffan-stacklet-design.md | 188 ------------ 2 files changed, 470 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-13-koffan-stacklet.md delete mode 100644 docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md diff --git a/docs/superpowers/plans/2026-07-13-koffan-stacklet.md b/docs/superpowers/plans/2026-07-13-koffan-stacklet.md deleted file mode 100644 index e9ceba0..0000000 --- a/docs/superpowers/plans/2026-07-13-koffan-stacklet.md +++ /dev/null @@ -1,282 +0,0 @@ -# koffan Stacklet Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `koffan` stacklet that runs the Koffan shared shopping-list web app as a family grocery list. - -**Architecture:** A single-container docker stacklet (image `ghcr.io/pansalut/koffan`) on the `stack` network, exposing port 42090 -> container 8080, with SQLite persisted on a bind mount. A shared login password is auto-generated by the framework and shown to the family via post-up hints. A Caddy snippet provides the domain-mode route. - -**Tech Stack:** Python 3.11 stdlib CLI framework, TOML manifests, Docker Compose, Caddy, pytest. - -## Global Constraints - -- Apple Silicon macOS only. -- Product name is always lowercase "famstack"; inside the codebase say "stack"/"stacklet". -- `id` == directory name: `koffan` (lowercase, no hyphens). -- Container name `stack-koffan`; every container joins the external `stack` network. -- Ports declared in `stacklet.toml` (`port = 42090`), bound in compose as `${PORT_BIND_IP:-...}:42090:8080`. -- Bind mounts only, no named Docker volumes. -- `restart: unless-stopped`; Watchtower label on every container. -- No `build:` in compose (no `build = true` in the manifest). -- `.env` is a derived artifact: never edit/commit/read it. -- The CLI is stdlib-only; tests get deps via the `test` extra. -- Test runner: `uv run --extra test pytest`. -- Commit prefixes: `feat:`, `fix:`, `docs:`, etc. No `Co-Authored-By:` trailers. Never commit to `main`; feature branches only. Never `git push` without explicit approval. -- No em dashes in user-facing text. - ---- - -### Task 1: Create the koffan stacklet (manifest, compose, caddy) with a validation test - -**Files:** -- Create: `stacklets/koffan/stacklet.toml` -- Create: `stacklets/koffan/docker-compose.yml` -- Create: `stacklets/koffan/caddy.snippet` -- Test: `tests/stacklets/test_koffan_manifest.py` - -**Interfaces:** -- Consumes: `stack.Stack(root, data, instance_dir=..., output=...)`; `Stack.discover()` returns a list of dicts each with keys `id`, `name`, `description`, `category`, `port`, `manifest`; `Stack.env(stacklet_id)` returns the rendered env dict and generates any `[env].generate` secrets into `instance_dir/.stack/secrets.toml`. -- Produces: a discoverable stacklet with `id == "koffan"`, `port == 42090`, that renders env keys `KOFFAN_DATA_DIR`, `TZ`, `DB_PATH`, `DEFAULT_LANG`, `APP_ENV`, `DISABLE_AUTH`, and a generated non-empty `APP_PASSWORD`. - -- [ ] **Step 1: Write the failing test** - -Create `tests/stacklets/test_koffan_manifest.py`: - -```python -"""koffan stacklet: manifest discovery and env rendering. - -Roots the Stack at the real repo (so it discovers the real koffan -stacklet) but points instance_dir/data at tmp_path so generated -secrets never touch the working tree. -""" - -import sys -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent.parent -_LIB_DIR = REPO_ROOT / "lib" -if str(_LIB_DIR) not in sys.path: - sys.path.insert(0, str(_LIB_DIR)) - - -@pytest.fixture -def koffan_stack(tmp_path): - """A Stack rooted at the real repo, with an isolated instance dir.""" - (tmp_path / "stack.toml").write_text( - '[core]\n' - 'domain = ""\n' - f'data_dir = "{tmp_path / "data"}"\n' - 'timezone = "Europe/Berlin"\n' - 'language = "en"\n' - '\n' - '[ai]\n' - 'default = "test-model"\n' - 'language = "en"\n' - '\n' - '[messages]\n' - 'server_name = "testserver"\n' - ) - (tmp_path / "users.toml").write_text( - '[[users]]\n' - 'name = "Test Admin"\n' - 'email = "admin@test.local"\n' - 'password = "testpass"\n' - 'role = "admin"\n' - ) - from stack import Stack - return Stack(root=REPO_ROOT, data=tmp_path / "data", instance_dir=tmp_path) - - -def test_manifest_discovered(koffan_stack): - by_id = {s["id"]: s for s in koffan_stack.discover()} - assert "koffan" in by_id - koffan = by_id["koffan"] - assert koffan["port"] == 42090 - assert koffan["category"] == "productivity" - - -def test_env_rendering(koffan_stack): - env = koffan_stack.env("koffan") - assert env["KOFFAN_DATA_DIR"].endswith("/koffan") - assert env["DB_PATH"] == "/data/shopping.db" - assert env["DEFAULT_LANG"] == "en" - assert env["APP_ENV"] == "development" - assert env["DISABLE_AUTH"] == "false" - assert env["TZ"] == "Europe/Berlin" - # Shared password is auto-generated and non-empty. - assert env["APP_PASSWORD"] -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `uv run --extra test pytest tests/stacklets/test_koffan_manifest.py -v` -Expected: FAIL — `test_manifest_discovered` asserts `"koffan" in by_id` and koffan does not exist yet (assertion error). - -- [ ] **Step 3: Create the manifest** - -Create `stacklets/koffan/stacklet.toml`: - -```toml -# stacklet.toml — koffan stacklet (shared shopping list) -# -# Koffan is an ultra-light shopping-list web app for couples and families. -# One shared password logs everyone in; the list syncs across phones and -# desktops in real time over a WebSocket. Data lives in a single SQLite -# file on a bind mount. - -id = "koffan" -name = "Koffan" -description = "Shared family shopping list (Koffan)" -version = "0.1.0" -category = "productivity" -port = 42090 - -hints = [ - "Open {url}", - "Log in with the shared password: {koffan__APP_PASSWORD}", - "Install it as an app from your phone browser (Add to Home Screen)", -] - -[upstream] -image = "ghcr.io/pansalut/koffan" -channel = "patch" - -# The shared login password is generated once and stored in -# .stack/secrets.toml as koffan__APP_PASSWORD. It survives across -# 'stack up' runs and is regenerated only after 'stack destroy'. -[env] -generate = ["APP_PASSWORD"] - -[env.defaults] -KOFFAN_DATA_DIR = "{data_dir}/koffan" -TZ = "{timezone}" -DB_PATH = "/data/shopping.db" -DEFAULT_LANG = "{language}" -# 'development' keeps the login cookie working over plain HTTP in port -# mode (the famstack default). 'production' would force the Secure cookie -# flag and break port-mode login. Domain mode (HTTPS) works fine here too. -APP_ENV = "development" -DISABLE_AUTH = "false" - -[health] -url = "http://localhost:42090" -expect = "200" -``` - -- [ ] **Step 4: Run the test to verify env rendering passes** - -Run: `uv run --extra test pytest tests/stacklets/test_koffan_manifest.py -v` -Expected: PASS — both `test_manifest_discovered` and `test_env_rendering` pass. - -- [ ] **Step 5: Create the compose file** - -Create `stacklets/koffan/docker-compose.yml`: - -```yaml -# stacklets/koffan/docker-compose.yml — Koffan shopping list -# -# Single Go binary with embedded SQLite. No database sidecar. The list -# is stored at /data/shopping.db on the KOFFAN_DATA_DIR bind mount. - -name: stack-koffan - -services: - stack-koffan: - container_name: stack-koffan - image: ghcr.io/pansalut/koffan:latest - labels: - - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}" - networks: - - stack - volumes: - - ${KOFFAN_DATA_DIR}:/data - environment: - APP_ENV: ${APP_ENV:-development} - APP_PASSWORD: ${APP_PASSWORD} - DISABLE_AUTH: ${DISABLE_AUTH:-false} - DB_PATH: ${DB_PATH:-/data/shopping.db} - DEFAULT_LANG: ${DEFAULT_LANG:-en} - TZ: ${TZ} - ports: - - "${PORT_BIND_IP:-127.0.0.1}:42090:8080" - restart: unless-stopped - -networks: - stack: - external: true -``` - -- [ ] **Step 6: Create the caddy snippet** - -Create `stacklets/koffan/caddy.snippet`: - -``` -# stacklets/koffan/caddy.snippet -# -# Assembled into the Caddyfile by 'stack up' in domain mode. Caddy runs -# in Docker on the stack network, so the backend is referenced by -# container name. reverse_proxy upgrades the WebSocket automatically, so -# real-time sync works without extra directives. - -koffan.{$FAMSTACK_DOMAIN} { - reverse_proxy stack-koffan:8080 -} -``` - -- [ ] **Step 7: Validate the compose file parses** - -Run: `docker compose -f stacklets/koffan/docker-compose.yml config -q && echo OK` -Expected: prints `OK` (unset env vars are fine here; `config -q` only checks structure). If `docker` is unavailable, skip this step. - -- [ ] **Step 8: Run the full stacklet test file again** - -Run: `uv run --extra test pytest tests/stacklets/test_koffan_manifest.py -v` -Expected: PASS — both tests still pass. - -- [ ] **Step 9: Commit** - -```bash -git add stacklets/koffan/stacklet.toml \ - stacklets/koffan/docker-compose.yml \ - stacklets/koffan/caddy.snippet \ - tests/stacklets/test_koffan_manifest.py -git commit -m "feat: add koffan shared shopping-list stacklet" -``` - ---- - -### Task 2: Run the fast test suite to confirm no regressions - -**Files:** -- None (verification only). - -**Interfaces:** -- Consumes: the committed koffan stacklet from Task 1. -- Produces: nothing; a green fast suite gate. - -- [ ] **Step 1: Run the framework and stacklet suites** - -Run: `uv run --extra test pytest tests/framework/ tests/stacklets/ -q` -Expected: PASS — all tests green, including the two new koffan tests. Discovery and env-rendering tests that iterate all stacklets must still pass with koffan present. - -- [ ] **Step 2: If anything fails** - -Read the failure. If a framework test that enumerates all stacklets fails because of a koffan field (e.g. an unexpected `category` value or a missing required manifest key), fix `stacklets/koffan/stacklet.toml` to match the convention the test enforces, then re-run Step 1. Do not weaken the failing test. - -- [ ] **Step 3: Commit any fix (only if Step 2 changed a file)** - -```bash -git add stacklets/koffan/stacklet.toml -git commit -m "fix: align koffan manifest with framework conventions" -``` - ---- - -## Notes for the implementer - -- **No hooks, no CLI plugins, no bot.** The stacklet is pure declaration plus one compose service. Do not add lifecycle hooks; the framework's up/down/destroy handles everything. -- **Backups are intentionally out of scope.** Do not add a `[[backup.archive]]` block. Koffan's SQLite file is rewritten in place and does not fit the append-only archive model (which has a ransomware canary and `min_files` check). This matches the current `photos` stacklet, whose Postgres data is likewise not yet backed up. -- **Do not run integration/Docker tests without asking the user first** — they collide with the user's running instance. The Task 1 Step 7 `docker compose config` check is a static parse, not a container start, so it is safe. -- **Reference for patterns:** `stacklets/chatai/` (single-service compose + caddy) and `stacklets/photos/stacklet.toml` (`[env].generate` + `[env.defaults]`). diff --git a/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md b/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md deleted file mode 100644 index ead515a..0000000 --- a/docs/superpowers/specs/2026-07-13-koffan-stacklet-design.md +++ /dev/null @@ -1,188 +0,0 @@ -# koffan stacklet - design - -Date: 2026-07-13 -Status: approved, ready for implementation - -## Purpose - -Add a `koffan` stacklet that runs [Koffan](https://github.com/PanSalut/Koffan), -a lightweight shared shopping-list web app, as a family grocery list. Koffan is -a single Go binary with SQLite storage, real-time WebSocket sync, and a PWA -front end. One shared password logs the whole family in. - -## Scope - -In scope: - -- A single-container docker stacklet following the framework conventions. -- Auto-generated shared login password surfaced to the family. -- Caddy route for domain mode. -- Language and timezone inherited from the stack config. - -Out of scope (v1): - -- Backups. Koffan stores a single mutable SQLite file (`/data/shopping.db`). - The only implemented backup mechanism is the append-only `[[backup.archive]]` - (files added, never modified, with a ransomware canary), which does not fit a - database that is rewritten in place. This mirrors the current state of the - `photos` stacklet, whose Postgres data is likewise not yet backed up. A - `[[backup.snapshot]]` primitive is planned framework-wide and would cover - Koffan later. -- Per-user accounts. Koffan has one shared password by design; we do not layer - famstack's per-user model on top. -- Bots, CLI plugins, and lifecycle hooks. None are needed. - -## Architecture - -Single service, no database sidecar (SQLite is embedded). - -``` -stacklets/koffan/ - stacklet.toml # manifest - docker-compose.yml # one service: stack-koffan - caddy.snippet # koffan.{domain} -> stack-koffan:8080 -``` - -### Service - -- Container name: `stack-koffan` -- Image: `ghcr.io/pansalut/koffan` (compose tag `:latest`; Watchtower manages - updates) -- Network: `stack` (external) -- Port: host `42090` -> container `8080`, bound as - `${PORT_BIND_IP:-127.0.0.1}:42090:8080` -- Volume: `${KOFFAN_DATA_DIR}:/data` (SQLite lives at `/data/shopping.db`) -- `restart: unless-stopped` -- Watchtower label: `com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}` - -Port `42090` is the next free slot in the 42xxx convention. Note that 42080 is -already taken by the infra stacklet's AdGuard admin panel (declared in its -`[ports]` table as `dns_admin`, not a top-level `port =`), so koffan skips it. - -## Manifest (`stacklet.toml`) - -```toml -id = "koffan" -name = "Koffan" -description = "Shared family shopping list (Koffan)" -version = "0.1.0" -category = "productivity" -port = 42090 - -hints = [ - "Open {url}", - "Log in with the shared password: {koffan__APP_PASSWORD}", - "Install it as an app from your phone browser (Add to Home Screen)", -] - -[upstream] -image = "ghcr.io/pansalut/koffan" -channel = "patch" - -[env] -generate = ["APP_PASSWORD"] - -[env.defaults] -KOFFAN_DATA_DIR = "{data_dir}/koffan" -TZ = "{timezone}" -DB_PATH = "/data/shopping.db" -DEFAULT_LANG = "{language}" -# development keeps cookies working over plain HTTP in port mode (the famstack -# default). production would force the Secure cookie flag and break port-mode -# login. Domain mode still works fine under development. -APP_ENV = "development" -DISABLE_AUTH = "false" - -[health] -url = "http://localhost:42090" -expect = "200" -``` - -Notes: - -- `generate = ["APP_PASSWORD"]` makes the framework mint a random password once, - store it in `.stack/secrets.toml` as `koffan__APP_PASSWORD`, inject it into the - container env, and expose it to hints as `{koffan__APP_PASSWORD}`. It survives - across `stack up` runs and is regenerated only after `stack destroy`. -- `DEFAULT_LANG` uses the `{language}` template var. Koffan supports - pl/en/de/es/fr/pt/uk/no/lt/el/sk/ru and falls back to `en` for anything else. - -## Data flow - -1. `stack up koffan` renders `.env` from `[env.defaults]`, generating - `APP_PASSWORD` on first run. -2. Docker Compose starts `stack-koffan`, mounting `{data_dir}/koffan` at `/data`. -3. Koffan serves the PWA on `:8080`; the family reaches it at - `http://:42090` (port mode) or `https://koffan.` (domain mode). -4. Family members log in once per device with the shared password; the cookie - keeps them signed in. - -## Compose (`docker-compose.yml`) - -```yaml -name: stack-koffan - -services: - stack-koffan: - container_name: stack-koffan - image: ghcr.io/pansalut/koffan:latest - labels: - - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}" - networks: - - stack - volumes: - - ${KOFFAN_DATA_DIR}:/data - environment: - APP_ENV: ${APP_ENV:-development} - APP_PASSWORD: ${APP_PASSWORD} - DISABLE_AUTH: ${DISABLE_AUTH:-false} - DB_PATH: ${DB_PATH:-/data/shopping.db} - DEFAULT_LANG: ${DEFAULT_LANG:-en} - TZ: ${TZ} - ports: - - "${PORT_BIND_IP:-127.0.0.1}:42090:8080" - restart: unless-stopped - -networks: - stack: - external: true -``` - -## Caddy route (`caddy.snippet`) - -``` -koffan.{$FAMSTACK_DOMAIN} { - reverse_proxy stack-koffan:8080 -} -``` - -Koffan uses a WebSocket for real-time sync; Caddy's `reverse_proxy` upgrades -WebSocket connections automatically, so no extra directive is required. - -## Error handling - -- Health check hits `http://localhost:42090` and expects `200`; a container that - fails to start shows as `failing`/`degraded` in `stack list` with the standard - framework hints. -- No custom hooks means no custom failure paths; the framework's up/down/destroy - lifecycle applies unchanged. - -## Testing - -- Add a per-stacklet unit test under `tests/stacklets/` that asserts the manifest - parses, declares port 42090, generates `APP_PASSWORD`, and renders the expected - env (mirroring existing per-stacklet tests). No Docker required. -- Follow existing patterns in `tests/stacklets/`; do not invent a new test shape. -- A Docker integration test is not warranted for v1: the stacklet crosses no - container boundary beyond a plain reverse proxy and adds no bespoke logic. - -## Success criteria - -1. `stack up koffan` on a fresh instance pulls the image, generates a password, - starts the container, and passes the health check. -2. The post-up hints print the URL and the shared password. -3. The list persists across `stack down` / `stack up` (SQLite file on the bind - mount). -4. In domain mode, `koffan.` reverse-proxies including WebSocket sync. -5. The new per-stacklet test passes under `uv run --extra test pytest - tests/stacklets/`.