@brendanbank/create-atrium-host 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENCE.md +25 -0
- package/README.md +94 -0
- package/bin/index.js +8 -0
- package/package.json +40 -0
- package/src/cli.js +279 -0
- package/src/git.js +48 -0
- package/src/names.js +91 -0
- package/src/render.js +90 -0
- package/template/.env.example +33 -0
- package/template/.github/dependabot.yml +56 -0
- package/template/.github/workflows/ci.yml +103 -0
- package/template/Dockerfile +46 -0
- package/template/Makefile +67 -0
- package/template/README.md +124 -0
- package/template/backend/alembic/env.py +116 -0
- package/template/backend/alembic/script.py.mako +27 -0
- package/template/backend/alembic/versions/0001_init.py +92 -0
- package/template/backend/alembic.ini +50 -0
- package/template/backend/pyproject.toml +17 -0
- package/template/backend/src/__HOST_PKG__/__init__.py +0 -0
- package/template/backend/src/__HOST_PKG__/bootstrap.py +52 -0
- package/template/backend/src/__HOST_PKG__/models.py +51 -0
- package/template/backend/src/__HOST_PKG__/router.py +73 -0
- package/template/backend/src/__HOST_PKG__/scripts/__init__.py +0 -0
- package/template/backend/src/__HOST_PKG__/scripts/seed_host_bundle.py +48 -0
- package/template/backend/tests/test_smoke.py +47 -0
- package/template/compose.yaml +96 -0
- package/template/frontend/package.json +36 -0
- package/template/frontend/src/__BRAND_PASCAL__AdminTab.tsx +32 -0
- package/template/frontend/src/__BRAND_PASCAL__Page.tsx +34 -0
- package/template/frontend/src/__BRAND_PASCAL__ProfileItem.tsx +38 -0
- package/template/frontend/src/__BRAND_PASCAL__Widget.tsx +117 -0
- package/template/frontend/src/api.ts +38 -0
- package/template/frontend/src/main.tsx +55 -0
- package/template/frontend/src/queryClient.ts +11 -0
- package/template/frontend/src/test/__BRAND_PASCAL__Widget.test.tsx +102 -0
- package/template/frontend/src/test/setup.ts +21 -0
- package/template/frontend/tsconfig.json +21 -0
- package/template/frontend/vite.config.ts +7 -0
- package/template/frontend/vitest.config.ts +15 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Host-side alembic config. Atrium owns its own chain in the runtime
|
|
2
|
+
# image (version table: alembic_version). The host owns this chain in
|
|
3
|
+
# alembic_version_app — see env.py. Run with:
|
|
4
|
+
#
|
|
5
|
+
# alembic -c /opt/host_app/alembic.ini upgrade head
|
|
6
|
+
#
|
|
7
|
+
# inside the api container after the atrium chain is up.
|
|
8
|
+
|
|
9
|
+
[alembic]
|
|
10
|
+
# %(here)s resolves to the directory of this ini file. Without it,
|
|
11
|
+
# script_location is interpreted relative to the CWD, which means
|
|
12
|
+
# `alembic -c /opt/host_app/alembic.ini ...` run from /app would load
|
|
13
|
+
# /app/alembic (atrium's chain) instead of the host's.
|
|
14
|
+
script_location = %(here)s/alembic
|
|
15
|
+
prepend_sys_path = %(here)s/src
|
|
16
|
+
sqlalchemy.url =
|
|
17
|
+
|
|
18
|
+
[loggers]
|
|
19
|
+
keys = root,sqlalchemy,alembic
|
|
20
|
+
|
|
21
|
+
[handlers]
|
|
22
|
+
keys = console
|
|
23
|
+
|
|
24
|
+
[formatters]
|
|
25
|
+
keys = generic
|
|
26
|
+
|
|
27
|
+
[logger_root]
|
|
28
|
+
level = WARN
|
|
29
|
+
handlers = console
|
|
30
|
+
qualname =
|
|
31
|
+
|
|
32
|
+
[logger_sqlalchemy]
|
|
33
|
+
level = WARN
|
|
34
|
+
handlers =
|
|
35
|
+
qualname = sqlalchemy.engine
|
|
36
|
+
|
|
37
|
+
[logger_alembic]
|
|
38
|
+
level = INFO
|
|
39
|
+
handlers =
|
|
40
|
+
qualname = alembic
|
|
41
|
+
|
|
42
|
+
[handler_console]
|
|
43
|
+
class = StreamHandler
|
|
44
|
+
args = (sys.stderr,)
|
|
45
|
+
level = NOTSET
|
|
46
|
+
formatter = generic
|
|
47
|
+
|
|
48
|
+
[formatter_generic]
|
|
49
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
50
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "__HOST_NAME__-host"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "__BRAND_NAME__ host extension for Atrium"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
|
|
11
|
+
# All runtime deps come from the atrium image (FastAPI, SQLAlchemy,
|
|
12
|
+
# Alembic, structlog). Listing them here would just shadow atrium's
|
|
13
|
+
# pinned versions. The package is installed via `pip install ./backend`
|
|
14
|
+
# inside the image so `__HOST_PKG__` is importable on PYTHONPATH.
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/__HOST_PKG__"]
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Atrium host bootstrap entry points.
|
|
2
|
+
|
|
3
|
+
The atrium image imports this module on startup when the operator sets
|
|
4
|
+
``ATRIUM_HOST_MODULE=__HOST_PKG__.bootstrap``:
|
|
5
|
+
|
|
6
|
+
- ``init_app(app)`` runs once during ``create_app()`` — after every
|
|
7
|
+
atrium router is included and before the ASGI app starts serving.
|
|
8
|
+
We use it to mount our router; permissions are seeded by the alembic
|
|
9
|
+
migration (``alembic/versions/0001_init.py``).
|
|
10
|
+
- ``init_worker(host)`` runs on worker startup, after atrium's
|
|
11
|
+
built-in handlers register and before APScheduler starts. ``host``
|
|
12
|
+
is a :class:`~app.host_sdk.worker.HostWorkerCtx` that exposes the
|
|
13
|
+
APScheduler instance plus a typed ``register_job_handler`` for
|
|
14
|
+
``scheduled_jobs`` dispatch.
|
|
15
|
+
|
|
16
|
+
Both functions are optional — a module that defines neither is
|
|
17
|
+
allowed (atrium logs ``host.init_app.absent`` and continues). Delete
|
|
18
|
+
either when you don't need it.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
|
|
24
|
+
from app.host_sdk.worker import HostWorkerCtx
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_app(app: FastAPI) -> None:
|
|
28
|
+
from .router import router
|
|
29
|
+
|
|
30
|
+
app.include_router(router)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init_worker(host: HostWorkerCtx) -> None:
|
|
34
|
+
# Recurring APScheduler tick — fires inside the worker process,
|
|
35
|
+
# NOT the api process. Use this for stateless idempotent work.
|
|
36
|
+
#
|
|
37
|
+
# from .schedule import tick
|
|
38
|
+
# host.scheduler.add_job(
|
|
39
|
+
# tick, "interval", seconds=30,
|
|
40
|
+
# id="__HOST_PKG__-tick", coalesce=True, max_instances=1,
|
|
41
|
+
# )
|
|
42
|
+
|
|
43
|
+
# Durable scheduled_jobs handler — each row is claimed FOR UPDATE
|
|
44
|
+
# SKIP LOCKED by exactly one worker, retried on failure.
|
|
45
|
+
#
|
|
46
|
+
# from .handlers import handle_thing
|
|
47
|
+
# host.register_job_handler(
|
|
48
|
+
# kind="__HOST_PKG__.thing",
|
|
49
|
+
# handler=handle_thing,
|
|
50
|
+
# description="Drain __HOST_PKG__.thing scheduled_jobs rows",
|
|
51
|
+
# )
|
|
52
|
+
_ = host # marker so type-checkers don't warn about the unused arg
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Host-side ORM models.
|
|
2
|
+
|
|
3
|
+
The host owns its own ``DeclarativeBase`` (and therefore its own
|
|
4
|
+
``MetaData``) so atrium's alembic chain never sees this table. The
|
|
5
|
+
host's alembic chain manages it under a separate version table
|
|
6
|
+
(``alembic_version_app``); see ``alembic/env.py``.
|
|
7
|
+
|
|
8
|
+
Rules of engagement:
|
|
9
|
+
|
|
10
|
+
- **Never** parent host tables on ``app.db.Base``. The next atrium
|
|
11
|
+
upgrade may collide with whatever you added.
|
|
12
|
+
- Cross-base foreign keys (a host column referencing ``users.id`` or
|
|
13
|
+
any other atrium table) need ``HostForeignKey`` from
|
|
14
|
+
``app.host_sdk.db`` plus ``emit_host_foreign_keys`` wired into
|
|
15
|
+
``alembic/env.py`` — both already configured in the scaffolded
|
|
16
|
+
files. See ``docs/host-models.md`` in the atrium repo for the full
|
|
17
|
+
rationale.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import BigInteger, Integer, String, text
|
|
24
|
+
from sqlalchemy.dialects.mysql import DATETIME as MysqlDATETIME
|
|
25
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HostBase(DeclarativeBase):
|
|
29
|
+
"""Host metadata, separate from atrium's ``app.db.Base``."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class __BRAND_PASCAL__State(HostBase):
|
|
33
|
+
"""Singleton row (id=1) for the demo widget. Replace with your
|
|
34
|
+
real domain models — and remove the corresponding migration row
|
|
35
|
+
when you do."""
|
|
36
|
+
|
|
37
|
+
__tablename__ = "__HOST_PKG___state"
|
|
38
|
+
|
|
39
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
|
40
|
+
message: Mapped[str] = mapped_column(
|
|
41
|
+
String(255), nullable=False, default="Welcome to __BRAND_NAME__"
|
|
42
|
+
)
|
|
43
|
+
counter: Mapped[int] = mapped_column(
|
|
44
|
+
BigInteger, nullable=False, default=0
|
|
45
|
+
)
|
|
46
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
47
|
+
MysqlDATETIME(fsp=6),
|
|
48
|
+
nullable=False,
|
|
49
|
+
server_default=text("CURRENT_TIMESTAMP(6)"),
|
|
50
|
+
server_onupdate=text("CURRENT_TIMESTAMP(6)"),
|
|
51
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Demo HTTP surface.
|
|
2
|
+
|
|
3
|
+
- ``GET /__HOST_PKG__/state`` — read-only, auth required.
|
|
4
|
+
- ``POST /__HOST_PKG__/bump`` — gated by ``__HOST_PKG__.write``,
|
|
5
|
+
increments the demo counter and writes an audit row.
|
|
6
|
+
|
|
7
|
+
Replace these with your real routes. The auth dependencies and the
|
|
8
|
+
audit/notify helpers (imported from ``app.*``) are the surface a
|
|
9
|
+
host calls atrium through.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
from sqlalchemy import select
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from app.auth.rbac import require_perm
|
|
19
|
+
from app.auth.users import current_user
|
|
20
|
+
from app.db import get_session
|
|
21
|
+
from app.models.auth import User
|
|
22
|
+
from app.services.audit import record as record_audit
|
|
23
|
+
|
|
24
|
+
from .models import __BRAND_PASCAL__State
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/__HOST_PKG__", tags=["__HOST_PKG__"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StateOut(BaseModel):
|
|
30
|
+
message: str
|
|
31
|
+
counter: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _load_state(session: AsyncSession) -> __BRAND_PASCAL__State:
|
|
35
|
+
state = (
|
|
36
|
+
await session.execute(
|
|
37
|
+
select(__BRAND_PASCAL__State).where(__BRAND_PASCAL__State.id == 1)
|
|
38
|
+
)
|
|
39
|
+
).scalar_one_or_none()
|
|
40
|
+
if state is None:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"__HOST_PKG___state row id=1 missing — run the host alembic upgrade",
|
|
43
|
+
)
|
|
44
|
+
return state
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/state", response_model=StateOut)
|
|
48
|
+
async def get_state(
|
|
49
|
+
_user: User = Depends(current_user),
|
|
50
|
+
session: AsyncSession = Depends(get_session),
|
|
51
|
+
) -> StateOut:
|
|
52
|
+
state = await _load_state(session)
|
|
53
|
+
return StateOut(message=state.message, counter=state.counter)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.post("/bump", response_model=StateOut)
|
|
57
|
+
async def bump(
|
|
58
|
+
user: User = Depends(require_perm("__HOST_PKG__.write")),
|
|
59
|
+
session: AsyncSession = Depends(get_session),
|
|
60
|
+
) -> StateOut:
|
|
61
|
+
state = await _load_state(session)
|
|
62
|
+
before = state.counter
|
|
63
|
+
state.counter += 1
|
|
64
|
+
await record_audit(
|
|
65
|
+
session,
|
|
66
|
+
actor_user_id=user.id,
|
|
67
|
+
entity="__HOST_PKG___state",
|
|
68
|
+
entity_id=state.id,
|
|
69
|
+
action="bump",
|
|
70
|
+
diff={"counter": {"before": before, "after": state.counter}},
|
|
71
|
+
)
|
|
72
|
+
await session.commit()
|
|
73
|
+
return StateOut(message=state.message, counter=state.counter)
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""One-shot CLI: write ``system.host_bundle_url`` so atrium picks up
|
|
2
|
+
the host frontend bundle on the next page load.
|
|
3
|
+
|
|
4
|
+
Run inside the api container after migrations + admin seeding:
|
|
5
|
+
|
|
6
|
+
python -m __HOST_PKG__.scripts.seed_host_bundle /host/main.js
|
|
7
|
+
|
|
8
|
+
Idempotent. Preserves other ``system`` fields by JSON-merging onto
|
|
9
|
+
whatever's already there.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import select
|
|
17
|
+
from sqlalchemy.dialects.mysql import insert
|
|
18
|
+
|
|
19
|
+
from app.db import get_session_factory
|
|
20
|
+
from app.models.ops import AppSetting
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _seed(url: str) -> None:
|
|
24
|
+
factory = get_session_factory()
|
|
25
|
+
async with factory() as session:
|
|
26
|
+
existing = (
|
|
27
|
+
await session.execute(
|
|
28
|
+
select(AppSetting).where(AppSetting.key == "system")
|
|
29
|
+
)
|
|
30
|
+
).scalar_one_or_none()
|
|
31
|
+
merged = {**(existing.value if existing else {}), "host_bundle_url": url}
|
|
32
|
+
|
|
33
|
+
stmt = insert(AppSetting).values(key="system", value=merged)
|
|
34
|
+
stmt = stmt.on_duplicate_key_update(value=merged)
|
|
35
|
+
await session.execute(stmt)
|
|
36
|
+
await session.commit()
|
|
37
|
+
print(f"system.host_bundle_url set to {url!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
if len(sys.argv) != 2:
|
|
42
|
+
print("usage: seed_host_bundle <url>", file=sys.stderr)
|
|
43
|
+
sys.exit(2)
|
|
44
|
+
asyncio.run(_seed(sys.argv[1]))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Smoke tests that don't need a running database.
|
|
2
|
+
|
|
3
|
+
The atrium runtime image strips ``pytest`` to keep the prod image
|
|
4
|
+
small; install it on demand with the ``test-backend`` Make target.
|
|
5
|
+
Tests here verify the package imports cleanly and the bootstrap
|
|
6
|
+
entry points have the right signatures — enough for CI to catch a
|
|
7
|
+
broken extension before it hits the api container.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_package_imports() -> None:
|
|
15
|
+
import __HOST_PKG__ # noqa: F401
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_bootstrap_signatures() -> None:
|
|
19
|
+
from __HOST_PKG__.bootstrap import init_app, init_worker
|
|
20
|
+
|
|
21
|
+
# Shape match against what atrium calls. The signatures are part
|
|
22
|
+
# of the contract; if they drift, fail loudly here rather than on
|
|
23
|
+
# api boot.
|
|
24
|
+
assert list(inspect.signature(init_app).parameters) == ["app"]
|
|
25
|
+
assert list(inspect.signature(init_worker).parameters) == ["host"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_router_mounted() -> None:
|
|
29
|
+
from __HOST_PKG__.router import router
|
|
30
|
+
|
|
31
|
+
paths = {route.path for route in router.routes}
|
|
32
|
+
assert "/__HOST_PKG__/state" in paths
|
|
33
|
+
assert "/__HOST_PKG__/bump" in paths
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_models_isolated_from_atrium_base() -> None:
|
|
37
|
+
"""HostBase must not share metadata with atrium's app.db.Base.
|
|
38
|
+
|
|
39
|
+
Sharing would mean autogenerate sees atrium tables and proposes
|
|
40
|
+
drop_table ops on every revision — the most painful failure
|
|
41
|
+
mode of the host-extension model. Catch it here.
|
|
42
|
+
"""
|
|
43
|
+
from app.db import Base as AtriumBase
|
|
44
|
+
|
|
45
|
+
from __HOST_PKG__.models import HostBase
|
|
46
|
+
|
|
47
|
+
assert HostBase.metadata is not AtriumBase.metadata
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# __BRAND_NAME__ — self-contained prod compose.
|
|
2
|
+
#
|
|
3
|
+
# Pulls atrium from GHCR, builds the host extension on top, brings up
|
|
4
|
+
# api + worker + mysql. No proxy, no TLS — atrium speaks plain HTTP on
|
|
5
|
+
# :8000 by design. Put your own TLS terminator (Caddy, nginx, the load
|
|
6
|
+
# balancer at your edge) in front for production.
|
|
7
|
+
#
|
|
8
|
+
# Usage (from this directory):
|
|
9
|
+
#
|
|
10
|
+
# cp .env.example .env # fill in JWT_SECRET, DB password
|
|
11
|
+
# docker compose up -d --build
|
|
12
|
+
# make migrate
|
|
13
|
+
# make seed-admin EMAIL=you@example.com PASSWORD=...
|
|
14
|
+
#
|
|
15
|
+
# Environment variables come from .env via ${VAR} interpolation.
|
|
16
|
+
|
|
17
|
+
services:
|
|
18
|
+
api:
|
|
19
|
+
build:
|
|
20
|
+
context: .
|
|
21
|
+
dockerfile: Dockerfile
|
|
22
|
+
args:
|
|
23
|
+
ATRIUM_IMAGE: ${ATRIUM_IMAGE:-ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__}
|
|
24
|
+
image: __HOST_NAME__:latest
|
|
25
|
+
pull_policy: never
|
|
26
|
+
restart: unless-stopped
|
|
27
|
+
environment:
|
|
28
|
+
ENVIRONMENT: ${ENVIRONMENT:-prod}
|
|
29
|
+
APP_TIMEZONE: ${APP_TIMEZONE:-UTC}
|
|
30
|
+
APP_SECRET_KEY: ${APP_SECRET_KEY}
|
|
31
|
+
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8000}
|
|
32
|
+
DATABASE_URL: ${DATABASE_URL}
|
|
33
|
+
JWT_SECRET: ${JWT_SECRET}
|
|
34
|
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: ${JWT_ACCESS_TOKEN_EXPIRE_MINUTES:-10080}
|
|
35
|
+
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
|
36
|
+
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-__BRAND_NAME__}
|
|
37
|
+
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-http://localhost:8000}
|
|
38
|
+
MAIL_BACKEND: ${MAIL_BACKEND:-console}
|
|
39
|
+
MAIL_FROM: ${MAIL_FROM:-no-reply@example.com}
|
|
40
|
+
ATRIUM_HOST_MODULE: __HOST_PKG__.bootstrap
|
|
41
|
+
# Host alembic runs with cwd=/opt/host_app (via -c
|
|
42
|
+
# /opt/host_app/alembic.ini) and its env.py imports from
|
|
43
|
+
# atrium's `app` package. Atrium's source lives at /app — put
|
|
44
|
+
# it on PYTHONPATH so the import resolves no matter where the
|
|
45
|
+
# caller's cwd is.
|
|
46
|
+
PYTHONPATH: /app
|
|
47
|
+
ports:
|
|
48
|
+
- "8000:8000"
|
|
49
|
+
depends_on:
|
|
50
|
+
mysql:
|
|
51
|
+
condition: service_healthy
|
|
52
|
+
|
|
53
|
+
worker:
|
|
54
|
+
image: __HOST_NAME__:latest
|
|
55
|
+
pull_policy: never
|
|
56
|
+
restart: unless-stopped
|
|
57
|
+
environment:
|
|
58
|
+
ENVIRONMENT: ${ENVIRONMENT:-prod}
|
|
59
|
+
APP_TIMEZONE: ${APP_TIMEZONE:-UTC}
|
|
60
|
+
APP_SECRET_KEY: ${APP_SECRET_KEY}
|
|
61
|
+
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8000}
|
|
62
|
+
DATABASE_URL: ${DATABASE_URL}
|
|
63
|
+
JWT_SECRET: ${JWT_SECRET}
|
|
64
|
+
MAIL_BACKEND: ${MAIL_BACKEND:-console}
|
|
65
|
+
MAIL_FROM: ${MAIL_FROM:-no-reply@example.com}
|
|
66
|
+
ATRIUM_HOST_MODULE: __HOST_PKG__.bootstrap
|
|
67
|
+
PYTHONPATH: /app
|
|
68
|
+
command: ["python", "-m", "app.worker"]
|
|
69
|
+
depends_on:
|
|
70
|
+
mysql:
|
|
71
|
+
condition: service_healthy
|
|
72
|
+
api:
|
|
73
|
+
condition: service_started
|
|
74
|
+
healthcheck:
|
|
75
|
+
disable: true
|
|
76
|
+
|
|
77
|
+
mysql:
|
|
78
|
+
image: mysql:8.0
|
|
79
|
+
restart: unless-stopped
|
|
80
|
+
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-time-zone=+00:00
|
|
81
|
+
environment:
|
|
82
|
+
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
|
83
|
+
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
|
84
|
+
MYSQL_USER: ${MYSQL_USER}
|
|
85
|
+
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
|
86
|
+
volumes:
|
|
87
|
+
- mysql_data:/var/lib/mysql
|
|
88
|
+
healthcheck:
|
|
89
|
+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
|
90
|
+
interval: 10s
|
|
91
|
+
timeout: 5s
|
|
92
|
+
retries: 10
|
|
93
|
+
|
|
94
|
+
volumes:
|
|
95
|
+
mysql_data:
|
|
96
|
+
name: __HOST_PKG___mysql_data
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__HOST_NAME__-frontend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Host extension bundle for atrium — registers a home widget, route, nav item, admin tab, and profile slot.",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"build:watch": "vite build --watch",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@brendanbank/atrium-host-bundle-utils": "^__ATRIUM_VERSION__",
|
|
16
|
+
"@brendanbank/atrium-host-types": "^__ATRIUM_VERSION__",
|
|
17
|
+
"@mantine/core": "^9.1.0",
|
|
18
|
+
"@mantine/hooks": "^9.1.0",
|
|
19
|
+
"@tabler/icons-react": "^3.41.1",
|
|
20
|
+
"@tanstack/react-query": "^5.100.1",
|
|
21
|
+
"react": "^19.2.5",
|
|
22
|
+
"react-dom": "^19.2.5"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@brendanbank/atrium-test-utils": "^__ATRIUM_VERSION__",
|
|
26
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
27
|
+
"@testing-library/react": "^16.3.2",
|
|
28
|
+
"@types/react": "^19.0.0",
|
|
29
|
+
"@types/react-dom": "^19.0.0",
|
|
30
|
+
"jsdom": "^29.0.2",
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
|
+
"vite": "^8.0.10",
|
|
33
|
+
"vite-plugin-css-injected-by-js": "^3.5.2",
|
|
34
|
+
"vitest": "^4.1.5"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MantineProvider, Stack, Text, Title } from '@mantine/core';
|
|
2
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
+
import { AtriumProvider } from '@brendanbank/atrium-host-bundle-utils/react';
|
|
4
|
+
|
|
5
|
+
import { __BRAND_PASCAL__Widget } from './__BRAND_PASCAL__Widget';
|
|
6
|
+
import { queryClient } from './queryClient';
|
|
7
|
+
|
|
8
|
+
function __BRAND_PASCAL__AdminTabInner() {
|
|
9
|
+
return (
|
|
10
|
+
<Stack gap="md">
|
|
11
|
+
<Title order={3}>__BRAND_NAME__ admin</Title>
|
|
12
|
+
<Text c="dimmed" size="sm">
|
|
13
|
+
Permission-gated tab in the admin shell. Atrium hides this tab
|
|
14
|
+
for users without ``__HOST_PKG__.write``; the API enforces the
|
|
15
|
+
same on every write.
|
|
16
|
+
</Text>
|
|
17
|
+
<__BRAND_PASCAL__Widget />
|
|
18
|
+
</Stack>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function __BRAND_PASCAL__AdminTab() {
|
|
23
|
+
return (
|
|
24
|
+
<MantineProvider>
|
|
25
|
+
<QueryClientProvider client={queryClient}>
|
|
26
|
+
<AtriumProvider>
|
|
27
|
+
<__BRAND_PASCAL__AdminTabInner />
|
|
28
|
+
</AtriumProvider>
|
|
29
|
+
</QueryClientProvider>
|
|
30
|
+
</MantineProvider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Container, Stack, Text, Title } from '@mantine/core';
|
|
2
|
+
import { MantineProvider } from '@mantine/core';
|
|
3
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import { AtriumProvider } from '@brendanbank/atrium-host-bundle-utils/react';
|
|
5
|
+
|
|
6
|
+
import { __BRAND_PASCAL__Widget } from './__BRAND_PASCAL__Widget';
|
|
7
|
+
import { queryClient } from './queryClient';
|
|
8
|
+
|
|
9
|
+
function __BRAND_PASCAL__PageInner() {
|
|
10
|
+
return (
|
|
11
|
+
<Container size="md" py="xl">
|
|
12
|
+
<Stack gap="md">
|
|
13
|
+
<Title order={2}>__BRAND_NAME__</Title>
|
|
14
|
+
<Text c="dimmed">
|
|
15
|
+
Dedicated route registered by the host bundle. Replace this
|
|
16
|
+
page with your real domain UI.
|
|
17
|
+
</Text>
|
|
18
|
+
<__BRAND_PASCAL__Widget />
|
|
19
|
+
</Stack>
|
|
20
|
+
</Container>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function __BRAND_PASCAL__Page() {
|
|
25
|
+
return (
|
|
26
|
+
<MantineProvider>
|
|
27
|
+
<QueryClientProvider client={queryClient}>
|
|
28
|
+
<AtriumProvider>
|
|
29
|
+
<__BRAND_PASCAL__PageInner />
|
|
30
|
+
</AtriumProvider>
|
|
31
|
+
</QueryClientProvider>
|
|
32
|
+
</MantineProvider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Card, MantineProvider, Stack, Text, Title } from '@mantine/core';
|
|
2
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
+
import { AtriumProvider, useMe } from '@brendanbank/atrium-host-bundle-utils/react';
|
|
4
|
+
|
|
5
|
+
import { queryClient } from './queryClient';
|
|
6
|
+
|
|
7
|
+
function __BRAND_PASCAL__ProfileItemInner() {
|
|
8
|
+
const { data: me } = useMe();
|
|
9
|
+
return (
|
|
10
|
+
<Card withBorder padding="lg" radius="md">
|
|
11
|
+
<Stack gap="xs">
|
|
12
|
+
<Title order={4}>__BRAND_NAME__</Title>
|
|
13
|
+
<Text size="sm" c="dimmed">
|
|
14
|
+
Per-user host extension card slotted after the role list.
|
|
15
|
+
Use this slot for app-specific identity bits — preferred
|
|
16
|
+
location, notification preferences, opt-in features.
|
|
17
|
+
</Text>
|
|
18
|
+
{me && (
|
|
19
|
+
<Text size="sm">
|
|
20
|
+
Signed in as <strong>{me.email}</strong>.
|
|
21
|
+
</Text>
|
|
22
|
+
)}
|
|
23
|
+
</Stack>
|
|
24
|
+
</Card>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function __BRAND_PASCAL__ProfileItem() {
|
|
29
|
+
return (
|
|
30
|
+
<MantineProvider>
|
|
31
|
+
<QueryClientProvider client={queryClient}>
|
|
32
|
+
<AtriumProvider>
|
|
33
|
+
<__BRAND_PASCAL__ProfileItemInner />
|
|
34
|
+
</AtriumProvider>
|
|
35
|
+
</QueryClientProvider>
|
|
36
|
+
</MantineProvider>
|
|
37
|
+
);
|
|
38
|
+
}
|