Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions stacklets/koffan/caddy.snippet
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions stacklets/koffan/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions stacklets/koffan/stacklet.toml
Original file line number Diff line number Diff line change
@@ -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"
64 changes: 64 additions & 0 deletions tests/stacklets/test_koffan_manifest.py
Original file line number Diff line number Diff line change
@@ -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"]