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..e14f674 --- /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 = "ShoppingList" +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"]