Skip to content

Commit 254e52a

Browse files
authored
fix(users): honor SM_USERS_BOOTSTRAP_* from .env (#50)
The README and .env.example tell users to set the first-boot admin credentials in .env, but the documented path silently did nothing: UsersSettings deliberately has no `env_file` (runtime fields come from the DB), and the `os.environ.get(...)` fallback doesn't see `.env`-only vars. Result: fresh `.env` + `make dev` created no admin, and logins failed with no clue why. - Add `simple_module_core.dotenv.parse_dotenv()` and swap the nearly identical parser already inlined in `simple_module_core.__main__` over to it. - In `users.bootstrap`, read the four `SM_USERS_BOOTSTRAP_*` keys from `.env` as a final fallback after settings → os.environ. Centralize the setting-attr → env-var mapping in `BOOTSTRAP_ENV_KEYS` so the test fixture and the resolver stay in sync. - Add commented-out bootstrap hints to `.env.example` and the `sm new`-scaffolded template so the knob is discoverable again (PR #49 trimmed it out). - Isolate users/tests/test_bootstrap.py from the developer's real `.env` via an autouse fixture.
1 parent 41403ed commit 254e52a

7 files changed

Lines changed: 105 additions & 28 deletions

File tree

‎.claude/settings.local.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"mcp__plugin_playwright_playwright__browser_evaluate",
77
"Skill(playwright-cli)",
88
"mcp__plugin_context7_context7__query-docs",
9-
"mcp__plugin_context7_context7__resolve-library-id"
9+
"mcp__plugin_context7_context7__resolve-library-id",
10+
"Bash(curl -fsS -o /dev/null http://127.0.0.1:8000/)"
1011
]
1112
}
1213
}

‎.env.example‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,11 @@ SM_SECRET_KEY=change-me-in-production
1212

1313
# Dev-only: Vite asset URL (ignored in production builds)
1414
SM_VITE_DEV_URL=http://localhost:5050
15+
16+
# First-boot admin seed (optional). Only applied when the users table is empty.
17+
# Leave unset and use `uv run sm-users create-admin` instead if you prefer.
18+
# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
19+
# SM_USERS_BOOTSTRAP_PASSWORD=changeme
20+
# Optional second non-admin seed user (handy in dev):
21+
# SM_USERS_BOOTSTRAP_USER_EMAIL=user@example.com
22+
# SM_USERS_BOOTSTRAP_USER_PASSWORD=changeme

‎framework/core/simple_module_core/__main__.py‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,18 @@
2525
run_diagnostics,
2626
)
2727
from simple_module_core.discovery import discover_modules, topological_sort
28+
from simple_module_core.dotenv import parse_dotenv
2829

2930

3031
def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
3132
"""Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset.
3233
3334
Reads env vars directly to avoid a dependency on ``simple_module_hosting``.
34-
Honors ``.env`` by reading it line-by-line if present in the cwd or
35-
``SM_PROJECT_ROOT`` (pydantic-settings isn't imported here).
35+
Honors ``.env`` by merging it into ``os.environ`` if present (pydantic-
36+
settings isn't imported here).
3637
"""
37-
root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
38-
dotenv = root / ".env"
39-
if dotenv.is_file():
40-
for raw in dotenv.read_text(encoding="utf-8").splitlines():
41-
line = raw.strip()
42-
if not line or line.startswith("#") or "=" not in line:
43-
continue
44-
key, _, value = line.partition("=")
45-
key = key.strip()
46-
value = value.strip().strip('"').strip("'")
47-
os.environ.setdefault(key, value)
38+
for key, value in parse_dotenv().items():
39+
os.environ.setdefault(key, value)
4840

4941
supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES")
5042
if not supported_raw:
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Minimal ``.env`` parser — dependency-free.
2+
3+
Used in places that can't or shouldn't pull in ``pydantic-settings`` (the
4+
diagnostics CLI runs before the host package is imported; the users-module
5+
bootstrap runs after settings are constructed and needs to read values that
6+
``UsersSettings`` deliberately omits from ``env_file``).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
from pathlib import Path
13+
14+
15+
def parse_dotenv(path: Path | None = None) -> dict[str, str]:
16+
"""Parse a ``.env`` file into a dict. Empty dict if the file is missing.
17+
18+
Values surrounded by matching single or double quotes have the quotes
19+
stripped. Does *not* handle escapes, ``export KEY=…``, or multiline
20+
values — keep the file simple. Does *not* mutate ``os.environ``; the
21+
caller decides whether to merge.
22+
23+
Without ``path``, looks up ``$SM_PROJECT_ROOT/.env`` (falling back to
24+
``$CWD/.env``) — the convention used by every tool in this repo.
25+
"""
26+
if path is None:
27+
root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
28+
path = root / ".env"
29+
if not path.is_file():
30+
return {}
31+
parsed: dict[str, str] = {}
32+
for raw in path.read_text(encoding="utf-8").splitlines():
33+
line = raw.strip()
34+
if not line or line.startswith("#") or "=" not in line:
35+
continue
36+
key, _, value = line.partition("=")
37+
parsed[key.strip()] = value.strip().strip('"').strip("'")
38+
return parsed

‎framework/hosting/simple_module_hosting/templates/host/.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ SM_VITE_DEV_URL=http://localhost:5050
1313

1414
# Optional: JSON array to restrict which installed modules load at boot.
1515
# SM_MODULES_ENABLED=["Auth","Products"]
16+
17+
# First-boot admin seed (optional). Only applied when the users table is empty.
18+
# Leave unset and use `uv run sm-users create-admin` instead if you prefer.
19+
# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
20+
# SM_USERS_BOOTSTRAP_PASSWORD=changeme

‎modules/users/tests/test_bootstrap.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from fastapi_users.password import PasswordHelper
99
from sqlalchemy import select
1010
from sqlalchemy.ext.asyncio import AsyncSession
11+
from users import bootstrap as bootstrap_module
1112
from users.bootstrap import (
13+
BOOTSTRAP_ENV_KEYS,
1214
CreateAdminResult,
1315
bootstrap_admin_from_env,
1416
create_admin,
@@ -17,6 +19,15 @@
1719
from users.models import Role, User, UserRole
1820
from users.settings import UsersSettings
1921

22+
23+
@pytest.fixture(autouse=True)
24+
def _isolate_from_repo_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
25+
"""Prevent the developer's ``.env`` from leaking bootstrap vars into tests."""
26+
monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
27+
for key in BOOTSTRAP_ENV_KEYS.values():
28+
monkeypatch.delenv(key, raising=False)
29+
30+
2031
# ---------------------------------------------------------------------------
2132
# Helpers
2233
# ---------------------------------------------------------------------------

‎modules/users/users/bootstrap.py‎

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from fastapi import FastAPI
1010
from fastapi_users.password import PasswordHelper
11+
from simple_module_core.dotenv import parse_dotenv
1112
from sqlalchemy import func, select
1213
from sqlalchemy.ext.asyncio import AsyncSession
1314

@@ -22,6 +23,15 @@
2223
from users.models import Role, User, UserRole
2324
from users.settings import UsersSettings
2425

26+
# Maps a UsersSettings attribute to the env var that seeds it on first boot.
27+
# Shared between the bootstrap function and the test fixture that isolates it.
28+
BOOTSTRAP_ENV_KEYS: dict[str, str] = {
29+
"bootstrap_email": "SM_USERS_BOOTSTRAP_EMAIL",
30+
"bootstrap_password": "SM_USERS_BOOTSTRAP_PASSWORD",
31+
"bootstrap_user_email": "SM_USERS_BOOTSTRAP_USER_EMAIL",
32+
"bootstrap_user_password": "SM_USERS_BOOTSTRAP_USER_PASSWORD",
33+
}
34+
2535
logger = logging.getLogger("users.bootstrap")
2636

2737
_EVT_CREATED = "users.bootstrap.created"
@@ -178,29 +188,41 @@ async def _user_table_is_empty(db: AsyncSession) -> bool:
178188
return count == 0
179189

180190

191+
def _read_dotenv_bootstrap_vars() -> dict[str, str]:
192+
"""Return SM_USERS_BOOTSTRAP_* entries from ``.env`` (ignore everything else).
193+
194+
``UsersSettings`` deliberately doesn't use ``env_file`` — runtime fields
195+
come from the DB, and pulling the whole ``.env`` in would re-expose every
196+
SMTP/cookie/token secret as an env knob. So we re-read ``.env`` just for
197+
the four documented seed keys.
198+
"""
199+
wanted = set(BOOTSTRAP_ENV_KEYS.values())
200+
return {k: v for k, v in parse_dotenv().items() if k in wanted}
201+
202+
181203
async def bootstrap_admin_from_env(app: FastAPI) -> None:
182204
"""On-startup hook: create admin from env vars iff users table is empty.
183205
184-
Reads ``SM_USERS_BOOTSTRAP_EMAIL`` + ``SM_USERS_BOOTSTRAP_PASSWORD`` via
185-
`UsersSettings`. If either is blank, returns silently. If the table
186-
already has users, returns silently (so restarts don't try to re-bootstrap).
206+
Resolves each of the four bootstrap fields in order: ``UsersSettings``
207+
(tests), then ``os.environ`` (docker/systemd), then ``.env`` (documented
208+
dev path). If the admin email or password is still blank, returns
209+
silently — same if the users table already has rows (so restarts don't
210+
try to re-bootstrap).
187211
188212
Optionally also creates a non-admin user from
189213
``SM_USERS_BOOTSTRAP_USER_EMAIL`` + ``SM_USERS_BOOTSTRAP_USER_PASSWORD`` —
190214
useful in dev for testing non-admin flows alongside the admin account.
191215
"""
192216
settings: UsersSettings = app.state.users.settings
193-
# Bootstrap fields are one-shot seed inputs, not runtime DB settings —
194-
# prefer the settings value if a test set one explicitly, otherwise fall
195-
# back to the env var so production deployments keep working.
196-
email = settings.bootstrap_email or os.environ.get("SM_USERS_BOOTSTRAP_EMAIL", "")
197-
password = settings.bootstrap_password or os.environ.get("SM_USERS_BOOTSTRAP_PASSWORD", "")
198-
user_email = settings.bootstrap_user_email or os.environ.get(
199-
"SM_USERS_BOOTSTRAP_USER_EMAIL", ""
200-
)
201-
user_password = settings.bootstrap_user_password or os.environ.get(
202-
"SM_USERS_BOOTSTRAP_USER_PASSWORD", ""
203-
)
217+
dotenv_vars = _read_dotenv_bootstrap_vars()
218+
resolved = {
219+
attr: getattr(settings, attr) or os.environ.get(env_key) or dotenv_vars.get(env_key, "")
220+
for attr, env_key in BOOTSTRAP_ENV_KEYS.items()
221+
}
222+
email = resolved["bootstrap_email"]
223+
password = resolved["bootstrap_password"]
224+
user_email = resolved["bootstrap_user_email"]
225+
user_password = resolved["bootstrap_user_password"]
204226

205227
if not email or not password:
206228
return

0 commit comments

Comments
 (0)