@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.
Files changed (40) hide show
  1. package/LICENCE.md +25 -0
  2. package/README.md +94 -0
  3. package/bin/index.js +8 -0
  4. package/package.json +40 -0
  5. package/src/cli.js +279 -0
  6. package/src/git.js +48 -0
  7. package/src/names.js +91 -0
  8. package/src/render.js +90 -0
  9. package/template/.env.example +33 -0
  10. package/template/.github/dependabot.yml +56 -0
  11. package/template/.github/workflows/ci.yml +103 -0
  12. package/template/Dockerfile +46 -0
  13. package/template/Makefile +67 -0
  14. package/template/README.md +124 -0
  15. package/template/backend/alembic/env.py +116 -0
  16. package/template/backend/alembic/script.py.mako +27 -0
  17. package/template/backend/alembic/versions/0001_init.py +92 -0
  18. package/template/backend/alembic.ini +50 -0
  19. package/template/backend/pyproject.toml +17 -0
  20. package/template/backend/src/__HOST_PKG__/__init__.py +0 -0
  21. package/template/backend/src/__HOST_PKG__/bootstrap.py +52 -0
  22. package/template/backend/src/__HOST_PKG__/models.py +51 -0
  23. package/template/backend/src/__HOST_PKG__/router.py +73 -0
  24. package/template/backend/src/__HOST_PKG__/scripts/__init__.py +0 -0
  25. package/template/backend/src/__HOST_PKG__/scripts/seed_host_bundle.py +48 -0
  26. package/template/backend/tests/test_smoke.py +47 -0
  27. package/template/compose.yaml +96 -0
  28. package/template/frontend/package.json +36 -0
  29. package/template/frontend/src/__BRAND_PASCAL__AdminTab.tsx +32 -0
  30. package/template/frontend/src/__BRAND_PASCAL__Page.tsx +34 -0
  31. package/template/frontend/src/__BRAND_PASCAL__ProfileItem.tsx +38 -0
  32. package/template/frontend/src/__BRAND_PASCAL__Widget.tsx +117 -0
  33. package/template/frontend/src/api.ts +38 -0
  34. package/template/frontend/src/main.tsx +55 -0
  35. package/template/frontend/src/queryClient.ts +11 -0
  36. package/template/frontend/src/test/__BRAND_PASCAL__Widget.test.tsx +102 -0
  37. package/template/frontend/src/test/setup.ts +21 -0
  38. package/template/frontend/tsconfig.json +21 -0
  39. package/template/frontend/vite.config.ts +7 -0
  40. package/template/frontend/vitest.config.ts +15 -0
@@ -0,0 +1,33 @@
1
+ ENVIRONMENT=dev
2
+ APP_TIMEZONE=UTC
3
+
4
+ # Long random strings. `openssl rand -hex 48` for each.
5
+ APP_SECRET_KEY=replace-me-with-openssl-rand-hex-48
6
+ JWT_SECRET=replace-me-with-openssl-rand-hex-48
7
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=10080
8
+
9
+ # The public URL the browser hits. Used in email links.
10
+ APP_BASE_URL=http://localhost:8000
11
+
12
+ # MySQL.
13
+ MYSQL_ROOT_PASSWORD=root-pw-change-me
14
+ MYSQL_DATABASE=__HOST_PKG__
15
+ MYSQL_USER=__HOST_PKG__
16
+ MYSQL_PASSWORD=db-pw-change-me
17
+ DATABASE_URL=mysql+aiomysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
18
+
19
+ # WebAuthn — the host the credential is bound to (no scheme/port) and
20
+ # the full origin the registration ceremony runs from. localhost is
21
+ # fine for dev; switch to your real hostname in prod.
22
+ WEBAUTHN_RP_ID=localhost
23
+ WEBAUTHN_RP_NAME=__BRAND_NAME__
24
+ WEBAUTHN_ORIGIN=http://localhost:8000
25
+
26
+ # Mail. `console` prints to stdout; switch to `smtp` and fill
27
+ # SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD in prod.
28
+ MAIL_BACKEND=console
29
+ MAIL_FROM=no-reply@example.com
30
+
31
+ # Pin the atrium base image. X.Y for patch uptake; X.Y.Z for fully
32
+ # deterministic deploys.
33
+ ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__
@@ -0,0 +1,56 @@
1
+ version: 2
2
+ # Each ecosystem declares two groups:
3
+ # - *-version-updates (applies-to: version-updates)
4
+ # - *-security-updates (applies-to: security-updates)
5
+ #
6
+ # Without the explicit security-updates group, Dependabot opens one
7
+ # PR per advisory even when the `groups` block is set for version
8
+ # updates.
9
+ updates:
10
+ - package-ecosystem: "pip"
11
+ directory: "/backend"
12
+ schedule:
13
+ interval: "weekly"
14
+ groups:
15
+ pip-version-updates:
16
+ applies-to: version-updates
17
+ patterns: ["*"]
18
+ pip-security-updates:
19
+ applies-to: security-updates
20
+ patterns: ["*"]
21
+
22
+ - package-ecosystem: "npm"
23
+ directory: "/frontend"
24
+ schedule:
25
+ interval: "weekly"
26
+ groups:
27
+ npm-version-updates:
28
+ applies-to: version-updates
29
+ patterns: ["*"]
30
+ npm-security-updates:
31
+ applies-to: security-updates
32
+ patterns: ["*"]
33
+
34
+ - package-ecosystem: "github-actions"
35
+ directory: "/"
36
+ schedule:
37
+ interval: "weekly"
38
+ groups:
39
+ actions-version-updates:
40
+ applies-to: version-updates
41
+ patterns: ["*"]
42
+ actions-security-updates:
43
+ applies-to: security-updates
44
+ patterns: ["*"]
45
+
46
+ - package-ecosystem: "docker"
47
+ directory: "/"
48
+ schedule:
49
+ interval: "weekly"
50
+ groups:
51
+ docker-version-updates:
52
+ applies-to: version-updates
53
+ patterns: ["*"]
54
+ docker-security-updates:
55
+ applies-to: security-updates
56
+ patterns: ["*"]
@@ -0,0 +1,103 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ paths-ignore:
6
+ - "**.md"
7
+ - "docs/**"
8
+ - "LICENCE.md"
9
+ push:
10
+ branches: [main, master]
11
+
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ frontend:
18
+ runs-on: ubuntu-latest
19
+ defaults:
20
+ run:
21
+ working-directory: frontend
22
+
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - uses: pnpm/action-setup@v4
27
+ with:
28
+ version: 10.33.1
29
+
30
+ - uses: actions/setup-node@v4
31
+ with:
32
+ node-version: "22"
33
+ cache: pnpm
34
+ cache-dependency-path: frontend/pnpm-lock.yaml
35
+
36
+ - name: Install deps
37
+ run: pnpm install --frozen-lockfile
38
+
39
+ - name: Typecheck
40
+ run: pnpm typecheck
41
+
42
+ - name: Unit tests
43
+ run: pnpm test
44
+
45
+ - name: Build
46
+ run: pnpm build
47
+
48
+ smoke:
49
+ # Stand up the full stack (atrium image + this host extension on
50
+ # top), run migrations, seed an admin, hit /readyz. Catches the
51
+ # bootstrap-time failures (missing env, broken init_app, alembic
52
+ # chain mismatch) the frontend job won't see.
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - uses: actions/checkout@v4
56
+
57
+ - name: Prepare smoke .env
58
+ run: |
59
+ cat > .env <<'EOF'
60
+ ENVIRONMENT=dev
61
+ APP_TIMEZONE=UTC
62
+ APP_SECRET_KEY=smoke-secret
63
+ APP_BASE_URL=http://localhost:8000
64
+ MYSQL_ROOT_PASSWORD=smoke-root-pw
65
+ MYSQL_DATABASE=__HOST_PKG__
66
+ MYSQL_USER=__HOST_PKG__
67
+ MYSQL_PASSWORD=smoke-pw
68
+ DATABASE_URL=mysql+aiomysql://__HOST_PKG__:smoke-pw@mysql:3306/__HOST_PKG__
69
+ JWT_SECRET=smoke-jwt-secret
70
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=10080
71
+ WEBAUTHN_RP_ID=localhost
72
+ WEBAUTHN_RP_NAME=__BRAND_NAME__
73
+ WEBAUTHN_ORIGIN=http://localhost:8000
74
+ MAIL_BACKEND=console
75
+ MAIL_FROM=no-reply@example.com
76
+ ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__
77
+ EOF
78
+
79
+ - name: Build + start stack
80
+ run: docker compose up -d --build
81
+
82
+ - name: Wait for API
83
+ run: |
84
+ for i in $(seq 1 40); do
85
+ if curl -fsS http://localhost:8000/readyz > /dev/null; then exit 0; fi
86
+ sleep 3
87
+ done
88
+ echo "API did not become ready" >&2
89
+ docker compose logs api
90
+ exit 1
91
+
92
+ - name: Run migrations
93
+ run: make migrate
94
+
95
+ - name: Seed admin
96
+ run: make seed-admin EMAIL=admin@example.com PASSWORD=smoke-pw-12345
97
+
98
+ - name: Backend smoke tests
99
+ run: make test-backend
100
+
101
+ - name: Dump stack logs on failure
102
+ if: failure()
103
+ run: docker compose logs
@@ -0,0 +1,46 @@
1
+ # __BRAND_NAME__ host extension image. Two stages:
2
+ #
3
+ # 1. frontend-builder — node + pnpm, builds the host SPA bundle
4
+ # (a single main.js dynamic-imported by atrium at runtime).
5
+ # 2. runtime — FROM the published atrium image, pip-installs the
6
+ # host backend package into atrium's venv, and copies the host
7
+ # bundle into /opt/atrium/static/host so atrium serves it at
8
+ # /host/main.js (same origin as the SPA, no CORS).
9
+
10
+ ARG ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__
11
+
12
+ # ---- frontend-builder ----
13
+ FROM node:25-alpine AS frontend-builder
14
+ WORKDIR /app
15
+ RUN npm install -g pnpm@10.33.1
16
+
17
+ COPY frontend/package.json frontend/pnpm-lock.yaml* frontend/.npmrc ./
18
+ RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
19
+ COPY frontend/ ./
20
+
21
+ ARG VITE_API_BASE_URL=""
22
+ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
23
+ RUN pnpm build
24
+
25
+ # ---- runtime ----
26
+ FROM ${ATRIUM_IMAGE} AS runtime
27
+
28
+ USER root
29
+ COPY backend /opt/host_app
30
+ # atrium's runtime image uses uv to build the venv but doesn't install
31
+ # pip into it. ensurepip bootstraps pip so we can install the host
32
+ # package — slightly slower than uv but avoids adding uv to the
33
+ # runtime image just for this one install.
34
+ RUN /opt/venv/bin/python -m ensurepip --upgrade \
35
+ && /opt/venv/bin/python -m pip install --no-cache-dir /opt/host_app
36
+
37
+ # Bake the host bundle into atrium's static dir at /host/main.js.
38
+ COPY --from=frontend-builder /app/dist /opt/atrium/static/host
39
+
40
+ USER app
41
+
42
+ # Re-declare HEALTHCHECK on the derived image. The base atrium image
43
+ # already ships one (curl /healthz on :8000), but image scanners flag
44
+ # absence at the leaf Dockerfile because they don't trace inheritance.
45
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
46
+ CMD curl -fsS http://localhost:8000/healthz || exit 1
@@ -0,0 +1,67 @@
1
+ # __BRAND_NAME__ — top-level Make targets.
2
+ #
3
+ # Convenience wrappers around the docker-compose stack. Most of these
4
+ # assume you ran `make dev-bootstrap` first to bring everything up.
5
+
6
+ COMPOSE := docker compose
7
+
8
+ .PHONY: help dev-bootstrap up down build logs ps migrate \
9
+ seed-admin seed-bundle test test-frontend test-backend typecheck
10
+
11
+ help: ## show this help
12
+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
13
+
14
+ dev-bootstrap: build up migrate ## build, start, migrate; run me first
15
+ @echo
16
+ @echo "Stack is up. Next: make seed-admin EMAIL=you@example.com PASSWORD=..."
17
+ @echo "Then: make seed-bundle && open http://localhost:8000"
18
+
19
+ up: ## start the stack
20
+ $(COMPOSE) up -d
21
+
22
+ down: ## stop the stack (keeps data volume)
23
+ $(COMPOSE) down
24
+
25
+ build: ## (re)build the image
26
+ $(COMPOSE) build
27
+
28
+ logs: ## tail logs from all services
29
+ $(COMPOSE) logs -f
30
+
31
+ ps: ## list services
32
+ $(COMPOSE) ps
33
+
34
+ migrate: ## run atrium + host alembic chains
35
+ $(COMPOSE) exec -T api alembic upgrade head
36
+ $(COMPOSE) exec -T api alembic -c /opt/host_app/alembic.ini upgrade head
37
+
38
+ seed-admin: ## seed a super_admin (EMAIL=... PASSWORD=... [FULL_NAME=...])
39
+ @if [ -z "$(EMAIL)" ] || [ -z "$(PASSWORD)" ]; then \
40
+ echo "usage: make seed-admin EMAIL=you@example.com PASSWORD=secret [FULL_NAME='Your Name']"; \
41
+ exit 2; \
42
+ fi
43
+ $(COMPOSE) exec -T api python -m app.scripts.seed_admin \
44
+ --email "$(EMAIL)" --password "$(PASSWORD)" \
45
+ --full-name "$${FULL_NAME:-Admin}" --super-admin
46
+
47
+ seed-bundle: ## point system.host_bundle_url at /host/main.js
48
+ $(COMPOSE) exec -T api python -m __HOST_PKG__.scripts.seed_host_bundle /host/main.js
49
+
50
+ # Tests. The atrium runtime image strips pytest/pytest-asyncio/httpx
51
+ # to keep the prod image small; install on demand. The pip install is
52
+ # idempotent (no-op if the deps are already there) but it does cost
53
+ # one round-trip per invocation — bake a `dev` Dockerfile stage if
54
+ # you'll be running tests often.
55
+ test: test-frontend test-backend ## run host tests (frontend + backend)
56
+
57
+ test-frontend: ## frontend unit tests via vitest
58
+ cd frontend && pnpm install --frozen-lockfile 2>/dev/null || (cd frontend && pnpm install)
59
+ cd frontend && pnpm test
60
+
61
+ test-backend: ## backend smoke tests inside the api container
62
+ $(COMPOSE) exec -u root -T api /opt/venv/bin/python -m pip install \
63
+ --quiet --no-cache-dir pytest pytest-asyncio httpx
64
+ $(COMPOSE) exec -T api /opt/venv/bin/python -m pytest /opt/host_app/tests
65
+
66
+ typecheck: ## tsc --noEmit on the host bundle
67
+ cd frontend && pnpm typecheck
@@ -0,0 +1,124 @@
1
+ # __BRAND_NAME__
2
+
3
+ A host extension on top of [atrium](https://github.com/brendanbank/atrium).
4
+ Atrium ships the platform layer (auth, RBAC, audit, email, jobs,
5
+ notifications, admin shell); this repo adds the domain-specific routes,
6
+ models, and UI.
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ cp .env.example .env
12
+ # Edit .env: set APP_SECRET_KEY, JWT_SECRET (openssl rand -hex 48 each),
13
+ # MYSQL_PASSWORD, MYSQL_ROOT_PASSWORD.
14
+
15
+ make dev-bootstrap
16
+ make seed-admin EMAIL=you@example.com PASSWORD='a-good-password'
17
+ make seed-bundle
18
+ open http://localhost:8000
19
+ ```
20
+
21
+ Sign in with the seeded admin and the **__BRAND_NAME__** card appears on
22
+ the home page, with a sidebar link to `/__HOST_NAME__`, an admin tab,
23
+ and a profile-page card. Bump the counter to exercise the RBAC + audit
24
+ path end to end.
25
+
26
+ ## Layout
27
+
28
+ ```
29
+ __HOST_NAME__/
30
+ Dockerfile # frontend-builder + FROM atrium runtime
31
+ compose.yaml # api + worker + mysql
32
+ .env.example # secrets template (copy to .env)
33
+ Makefile # dev-bootstrap / migrate / seed-* / test
34
+ backend/ # host Python package (__HOST_PKG__)
35
+ pyproject.toml
36
+ alembic.ini
37
+ alembic/ # alembic_version_app chain (separate from atrium)
38
+ src/__HOST_PKG__/
39
+ bootstrap.py # init_app(app), init_worker(host)
40
+ models.py # HostBase + your tables
41
+ router.py # FastAPI routes
42
+ scripts/seed_host_bundle.py
43
+ tests/ # pytest smoke tests
44
+ frontend/ # Vite library project (single main.js)
45
+ package.json
46
+ vite.config.ts # uses @brendanbank/atrium-host-bundle-utils/vite
47
+ src/
48
+ main.tsx # registry calls — ~10 lines
49
+ api.ts
50
+ queryClient.ts
51
+ __BRAND_PASCAL__Widget.tsx # home widget + admin tab + page demo
52
+ __BRAND_PASCAL__Page.tsx
53
+ __BRAND_PASCAL__AdminTab.tsx
54
+ __BRAND_PASCAL__ProfileItem.tsx
55
+ src/test/ # vitest setup + worked example using @brendanbank/atrium-test-utils
56
+ .github/workflows/ # CI (typecheck + tests + smoke)
57
+ ```
58
+
59
+ ## What atrium gives you (don't reimplement)
60
+
61
+ - Auth (password / TOTP / email OTP / WebAuthn) with role-mandatory 2FA
62
+ - RBAC: roles + permissions + super_admin + impersonation
63
+ - Account lifecycle: invite-only or self-serve signup, soft-delete + grace + hard-delete
64
+ - Audit log + retention pruning
65
+ - Email pipeline: templates per locale, durable outbox, retry/backoff
66
+ - In-app notifications + SSE bell
67
+ - Admin app-config (branding, system flags, translations, auth toggles)
68
+ - Maintenance mode (super_admin bypass)
69
+
70
+ If you find yourself writing one of these, stop — atrium has it. See
71
+ the atrium repo's `CLAUDE.md` for the contract.
72
+
73
+ ## What lives in this repo
74
+
75
+ - Your domain models (on `HostBase`, never `app.db.Base`)
76
+ - Your routes (gated by `current_user` or `require_perm("...")`)
77
+ - Your migrations (on the `alembic_version_app` chain)
78
+ - Your frontend pages / widgets / admin tabs
79
+ - Your background jobs (`host.scheduler.add_job` for ticks,
80
+ `host.register_job_handler` for durable queue work)
81
+
82
+ ## Adding things
83
+
84
+ | You want to add | Where it lives | Wire it via |
85
+ |-------------------------------|------------------------------------------------------|----------------------------------------------------------------------------|
86
+ | HTTP endpoint | `__HOST_PKG__/router.py` | `app.include_router(router)` in `init_app` |
87
+ | New permission | A new alembic migration | `seed_permissions_sync(op.get_bind(), [...], grants={...})` |
88
+ | Recurring tick | `__HOST_PKG__/schedule.py` async function | `host.scheduler.add_job(fn, "interval", seconds=N, ...)` in `init_worker` |
89
+ | Durable async job | A handler `(session, job, payload) -> None` | `host.register_job_handler(kind="...", handler=..., description="...")` |
90
+ | Admin-tunable flag | A Pydantic `BaseModel` config class | `register_namespace("ns", Model, public=False)` in `init_app` |
91
+ | Per-user notification | Inside the txn that mutated the row | `from app.services.notifications import notify_user` |
92
+ | Outbound email (queued) | A template row in `email_templates` + a callsite | `from app.email.sender import enqueue_and_log` |
93
+ | Audit row | Inside the txn that mutated the row | `from app.services.audit import record` |
94
+ | Home widget | A React component in `frontend/src/` | `reg.registerHomeWidget({ key, render })` |
95
+ | Dedicated route | A page component | `reg.registerRoute({ key, path, element, layout? })` |
96
+ | Sidebar link | A label + path | `reg.registerNavItem({ key, label, to, icon? })` |
97
+ | Admin tab | A component, gated by a permission | `reg.registerAdminTab({ key, label, icon?, perm, element })` |
98
+ | Profile-page card | A component | `reg.registerProfileItem({ key, slot?, render, condition? })` |
99
+
100
+ For the full backend extension surface see
101
+ [atrium's `docs/new-project/README.md`](https://github.com/brendanbank/atrium/blob/master/docs/new-project/README.md).
102
+
103
+ ## Tests
104
+
105
+ ```bash
106
+ make test # frontend unit tests + backend smoke tests
107
+ make typecheck # tsc --noEmit on the host bundle
108
+ ```
109
+
110
+ ## Pinning atrium
111
+
112
+ `compose.yaml` reads `ATRIUM_IMAGE` from `.env` (default
113
+ `ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__`). Override to test
114
+ against a specific release:
115
+
116
+ ```bash
117
+ ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:0.15.0 make build up
118
+ ```
119
+
120
+ The frontend SDK packages
121
+ (`@brendanbank/atrium-host-types`, `@brendanbank/atrium-host-bundle-utils`,
122
+ `@brendanbank/atrium-test-utils`) version in lockstep with the image
123
+ — a pin of `^__ATRIUM_VERSION__` matches any patch of the
124
+ `__ATRIUM_VERSION__.x` line.
@@ -0,0 +1,116 @@
1
+ """Host-side alembic environment.
2
+
3
+ Three things differ from atrium's ``backend/alembic/env.py``:
4
+
5
+ 1. ``target_metadata`` points at the host's ``HostBase.metadata`` so
6
+ autogenerate only sees host tables. Atrium's tables are managed by
7
+ atrium's own chain — never touch them from here.
8
+ 2. ``version_table`` is set to ``alembic_version_app`` so the two
9
+ chains track their heads independently. They share the database
10
+ but never collide on a revision.
11
+ 3. ``process_revision_directives`` is wired to
12
+ ``emit_host_foreign_keys`` so columns declared with
13
+ ``HostForeignKey()`` get their cross-base ``ForeignKeyConstraint``
14
+ emitted into autogenerated migrations.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from logging.config import fileConfig
20
+
21
+ from sqlalchemy import pool
22
+ from sqlalchemy.engine import Connection
23
+ from sqlalchemy.ext.asyncio import async_engine_from_config
24
+
25
+ from alembic import context
26
+ from app.host_sdk.alembic import emit_host_foreign_keys
27
+ from app.settings import get_settings
28
+
29
+ # ``HostBase`` is the metadata target; importing it from
30
+ # ``__HOST_PKG__.models`` also runs the package's ``__init__``, which
31
+ # registers every model class on ``HostBase.metadata`` before
32
+ # autogenerate inspects it.
33
+ from __HOST_PKG__.models import HostBase
34
+
35
+ VERSION_TABLE = "alembic_version_app"
36
+
37
+ config = context.config
38
+
39
+ if config.config_file_name is not None:
40
+ fileConfig(config.config_file_name)
41
+
42
+ if not config.get_main_option("sqlalchemy.url") or config.get_main_option(
43
+ "sqlalchemy.url"
44
+ ).startswith("driver://"):
45
+ config.set_main_option("sqlalchemy.url", get_settings().database_url)
46
+
47
+ target_metadata = HostBase.metadata
48
+
49
+
50
+ def _include_object(obj, name, type_, reflected, compare_to):
51
+ """Filter atrium tables out of autogenerate.
52
+
53
+ Both atrium and the host write to the same MySQL database. With
54
+ ``target_metadata = HostBase.metadata`` alembic sees atrium's
55
+ tables as "in the DB but not in the model" and would emit
56
+ ``drop_table`` ops for every one of them on the first
57
+ autogenerate. Only emit ops for tables (and child objects) that
58
+ live in ``HostBase.metadata``.
59
+ """
60
+ if type_ == "table":
61
+ return name in target_metadata.tables
62
+ if reflected:
63
+ parent = getattr(obj, "table", None)
64
+ if parent is not None and parent.name not in target_metadata.tables:
65
+ return False
66
+ return True
67
+
68
+
69
+ def run_migrations_offline() -> None:
70
+ url = config.get_main_option("sqlalchemy.url")
71
+ context.configure(
72
+ url=url,
73
+ target_metadata=target_metadata,
74
+ literal_binds=True,
75
+ dialect_opts={"paramstyle": "named"},
76
+ version_table=VERSION_TABLE,
77
+ include_object=_include_object,
78
+ process_revision_directives=emit_host_foreign_keys,
79
+ )
80
+ with context.begin_transaction():
81
+ context.run_migrations()
82
+
83
+
84
+ def do_run_migrations(connection: Connection) -> None:
85
+ context.configure(
86
+ connection=connection,
87
+ target_metadata=target_metadata,
88
+ version_table=VERSION_TABLE,
89
+ compare_type=True,
90
+ render_as_batch=False,
91
+ include_object=_include_object,
92
+ process_revision_directives=emit_host_foreign_keys,
93
+ )
94
+ with context.begin_transaction():
95
+ context.run_migrations()
96
+
97
+
98
+ async def run_async_migrations() -> None:
99
+ connectable = async_engine_from_config(
100
+ config.get_section(config.config_ini_section, {}),
101
+ prefix="sqlalchemy.",
102
+ poolclass=pool.NullPool,
103
+ )
104
+ async with connectable.connect() as connection:
105
+ await connection.run_sync(do_run_migrations)
106
+ await connectable.dispose()
107
+
108
+
109
+ def run_migrations_online() -> None:
110
+ asyncio.run(run_async_migrations())
111
+
112
+
113
+ if context.is_offline_mode():
114
+ run_migrations_offline()
115
+ else:
116
+ run_migrations_online()
@@ -0,0 +1,27 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ ${imports if imports else ""}
15
+
16
+ revision: str = ${repr(up_revision)}
17
+ down_revision: Union[str, None] = ${repr(down_revision)}
18
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
19
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
20
+
21
+
22
+ def upgrade() -> None:
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,92 @@
1
+ """__HOST_PKG__ initial schema + brand seed
2
+
3
+ Revision ID: 0001_init
4
+ Revises:
5
+ Create Date: 2026-04-28
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Sequence
11
+
12
+ import sqlalchemy as sa
13
+ from alembic import op
14
+ from app.auth.rbac_seed import seed_permissions_sync
15
+ from sqlalchemy.dialects.mysql import DATETIME as MysqlDATETIME
16
+
17
+ revision: str = "0001_init"
18
+ down_revision: str | None = None
19
+ branch_labels: str | Sequence[str] | None = None
20
+ depends_on: str | Sequence[str] | None = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ # Demo singleton table — delete this and `__HOST_PKG__/models.py`
25
+ # once you've added your real domain models. The router and the
26
+ # frontend widget reference it; remove those callsites at the
27
+ # same time.
28
+ op.create_table(
29
+ "__HOST_PKG___state",
30
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=False),
31
+ sa.Column(
32
+ "message",
33
+ sa.String(length=255),
34
+ nullable=False,
35
+ server_default=sa.text("'Welcome to __BRAND_NAME__'"),
36
+ ),
37
+ sa.Column(
38
+ "counter",
39
+ sa.BigInteger(),
40
+ nullable=False,
41
+ server_default=sa.text("0"),
42
+ ),
43
+ sa.Column(
44
+ "updated_at",
45
+ MysqlDATETIME(fsp=6),
46
+ nullable=False,
47
+ server_default=sa.text("CURRENT_TIMESTAMP(6)"),
48
+ server_onupdate=sa.text("CURRENT_TIMESTAMP(6)"),
49
+ ),
50
+ )
51
+
52
+ # Singleton row so the demo route doesn't have to lazy-create.
53
+ op.execute(
54
+ "INSERT INTO __HOST_PKG___state (id, message, counter) "
55
+ "VALUES (1, 'Welcome to __BRAND_NAME__', 0)"
56
+ )
57
+
58
+ # Permissions belong with the schema. The runtime form
59
+ # (`seed_permissions`) exists for hosts that discover permissions
60
+ # at startup; for static codes, the migration form is the natural
61
+ # fit. Atrium auto-grants every seeded permission to super_admin;
62
+ # `grants` adds extra role bindings.
63
+ seed_permissions_sync(
64
+ op.get_bind(),
65
+ ["__HOST_PKG__.read", "__HOST_PKG__.write"],
66
+ grants={"admin": ["__HOST_PKG__.write"]},
67
+ )
68
+
69
+ # Materialise an initial BrandConfig so the SPA renders with the
70
+ # host's name + primary colour from the first page load. Atrium
71
+ # exposes this via /app-config and re-reads it within the 2 s
72
+ # cache TTL when the admin tweaks branding live.
73
+ brand = json.dumps(
74
+ {
75
+ "name": "__BRAND_NAME__",
76
+ "preset": "default",
77
+ "overrides": {"primaryColor": "__BRAND_PRIMARY__"},
78
+ }
79
+ )
80
+ op.execute(
81
+ sa.text(
82
+ "INSERT INTO app_settings (`key`, value) VALUES ('brand', :v) "
83
+ "ON DUPLICATE KEY UPDATE value = :v"
84
+ ).bindparams(v=brand)
85
+ )
86
+
87
+
88
+ def downgrade() -> None:
89
+ # Permissions intentionally left in place — they're cheap to keep
90
+ # and removing them would orphan any UI that still references the
91
+ # code. Brand seed left alone for the same reason.
92
+ op.drop_table("__HOST_PKG___state")