Skip to content
Merged
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,35 @@ All notable changes to this project are documented in this file. The format is b
*complete* verdict now keeps answering while one refresh runs.

### Security
- `smpy new` no longer writes generated secrets into the committed
`.env.example` (#441). `SM_SECRET_KEY` and the users token secrets go to a
gitignored `.env` (created if absent; an existing one only gains missing
keys), and `.env.example` keeps `# KEY=` placeholders. Apps scaffolded
earlier should rotate those secrets if `.env.example` was committed.
- The scaffold's `docker-compose.yml` no longer publishes Postgres on
`0.0.0.0:5432` with `postgres`/`postgres` while the app runs in production
mode (#444). The password comes from `POSTGRES_PASSWORD` (generated into
`.env`; compose refuses to start without it), and Postgres and Redis bind
`127.0.0.1` only.
- The tenant header (`tenant_header`) is no longer honoured for an
authenticated user without a tenant of their own: such a user could name any
tenant. On the legacy path it applies to anonymous requests only; with the
`tenants` resolver it selects among the user's own memberships.

### Fixed
- `render_item` renders sqlmodel>=0.0.48's `UTCDateTime` as
`sa.DateTime(timezone=True)` and adds `import sqlmodel` for any other
sqlmodel type, so an initial autogenerate no longer emits a migration that
dies with `NameError: name 'sqlmodel' is not defined` (#440).
- `smpy add <path>` for a module inside the host's uv workspace writes
`{ workspace = true }` and an unpinned dependency instead of a path source uv
rejects (#442). A relative path missing under `--path` falls back to the cwd,
and `smpy create-module` inside a workspace prints the `smpy add` command.
- Scaffold hygiene (#443): templates name the `smpy` CLI (not `sm`), `uv.lock`
is committed rather than gitignored, and the Postgres database name is
snake_case (`demo_app`, not `demo-app`).
- Scaffolded dev groups floor `playwright>=1.45`; without it uv resolved
playwright 1.18 and every `pytest` run failed at plugin load (#445).
- `/admin/background-tasks` returned 500 on Postgres (#413): `TaskExecution`'s
timestamps were declared naive although the columns are `timestamptz` since
`e5f2a8c1d7b3`. This was already fixed on `main` by #406 (unreleased since
Expand Down
9 changes: 6 additions & 3 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Hit `http://localhost:8000`. You should see the landing page.

## Database choice

`smpy new` writes a `.env.example`. The default is SQLite (zero setup):
`smpy new` writes a committed `.env.example` (placeholders only) and a gitignored `.env` holding the generated secrets. The default is SQLite (zero setup):

```bash
SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
Expand All @@ -85,10 +85,13 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
For Postgres, scaffold with `--db postgres` (or pick Postgres in the wizard) and `smpy new` writes a matching `SM_DATABASE_URL`:

```bash
SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/myapp
POSTGRES_PASSWORD=<generated>
SM_DATABASE_URL=postgresql+asyncpg://postgres:<generated>@localhost:5432/my_app
```

Every scaffold ships a `docker-compose.yml` that matches your `--db` choice (a migration history is dialect-frozen at autogenerate time, so containers must run the same database the migrations were generated against). With `--db postgres` it brings up `postgres` (db named after your app, user `postgres`/`postgres`) on `:5432` and the `app` service; the default SQLite scaffold runs the `app` container alone with the database on a named volume. Including the `background_tasks` module adds `redis` on `:6379` plus `worker`/`beat` services that reuse the app image. With `--db postgres`, the container also doubles as a local dev database:
The database name is the snake_case app name. `POSTGRES_PASSWORD` is shared with the compose `postgres` service below.

Every scaffold ships a `docker-compose.yml` that matches your `--db` choice (a migration history is dialect-frozen at autogenerate time, so containers must run the same database the migrations were generated against). With `--db postgres` it brings up `postgres` (db named after your app, user `postgres`, password from `POSTGRES_PASSWORD` in `.env`) on `127.0.0.1:5432` and the `app` service; the default SQLite scaffold runs the `app` container alone with the database on a named volume. Including the `background_tasks` module adds `redis` on `127.0.0.1:6379` plus `worker`/`beat` services that reuse the app image. With `--db postgres`, the container also doubles as a local dev database:

```bash
docker compose up -d postgres
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
myapp/
├── pyproject.toml # uv workspace root (members: host, modules/*) — not itself installed
├── package.json # npm workspace root (host/client_app, modules/*)
├── .env.example # default env vars; copy to .env
├── .env.example # committed template, no secrets
├── .env # generated secrets (gitignored)
├── Makefile # thin wrapper: install / dev / migrate / build / gen-pages
├── README.md
│
Expand Down
8 changes: 7 additions & 1 deletion framework/cli/simple_module_cli/_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@

from __future__ import annotations

__all__ = ["set_env_key"]
__all__ = ["env_values", "set_env_key"]


def set_env_key(text: str, key: str, value: str) -> str:
"""Replace or append ``KEY=VALUE`` in an env-style file body."""
lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")]
lines.append(f"{key}={value}")
return "\n".join(lines) + "\n"


def env_values(text: str) -> dict[str, str]:
"""Parse the ``KEY=VALUE`` lines of an env-style file body (comments skipped)."""
lines = text.splitlines()
return dict(ln.split("=", 1) for ln in lines if "=" in ln and not ln.startswith("#"))
72 changes: 72 additions & 0 deletions framework/cli/simple_module_cli/_local_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Split a new app's env file into a committed template and a local ``.env``.

``.env.example`` is committed, so it only ever carries placeholders. The
secrets ``smpy new`` generates go to ``.env``, which every scaffold's
``.gitignore`` excludes (GH #441). An existing ``.env`` is never overwritten:
only keys it does not define yet are added.
"""

from __future__ import annotations

import secrets
from collections.abc import Iterable
from pathlib import Path

from simple_module_cli._env import env_values, set_env_key

__all__ = ["comment_out_env_key", "write_local_env"]

_SECRET_NOTE = (
"# Secrets — `smpy new` generated these into .env (gitignored); keep them\n"
"# out of this committed file. Set real values wherever you deploy."
)


def comment_out_env_key(text: str, key: str) -> str:
"""Turn ``KEY=...`` into a bare ``# KEY=`` placeholder, in place if present."""
lines = text.splitlines()
placeholder = f"# {key}="
hits = [i for i, ln in enumerate(lines) if ln.startswith((f"{key}=", placeholder))]
if hits:
lines[hits[0]] = placeholder
lines = [ln for i, ln in enumerate(lines) if i not in hits[1:]]
else:
if _SECRET_NOTE.splitlines()[0] not in lines:
lines += ["", *_SECRET_NOTE.splitlines()]
lines.append(placeholder)
return "\n".join(lines) + "\n"


def write_local_env(
target: Path, *, secret_keys: Iterable[str], overrides: dict[str, str] | None = None
) -> None:
"""Write ``target/.env`` from ``.env.example`` plus generated secrets.

``secret_keys`` get a fresh random value each; ``overrides`` are written
verbatim (e.g. a database URL carrying a generated password). Overrides
belong together, so an existing ``.env`` defining any of them gets none:
a fresh URL must not disagree with a kept ``POSTGRES_PASSWORD``.
"""
env_path = target / ".env"
existing = env_path.exists()
source = env_path if existing else target / ".env.example"
text = source.read_text(encoding="utf-8") if source.exists() else ""
defined = env_values(text).keys()
overrides = overrides or {}
if existing and defined & overrides.keys():
overrides = {}
values = {key: secrets.token_urlsafe(32) for key in secret_keys} | overrides
for key, value in values.items():
if existing and key in defined:
continue
placeholder = f"# {key}="
lines = text.splitlines()
if placeholder in lines:
# Line-based, so a placeholder on the last line (no trailing
# newline) is handled too.
lines[lines.index(placeholder)] = f"{key}={value}"
text = "\n".join(lines) + "\n"
else:
text = set_env_key(text, key, value)
env_path.write_text(text, encoding="utf-8")
env_path.chmod(0o600) # holds generated secrets
41 changes: 36 additions & 5 deletions framework/cli/simple_module_cli/add_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import os
import subprocess
import tempfile
from collections.abc import Callable
Expand All @@ -35,7 +36,9 @@
write_dependency,
write_git_source,
write_path_source,
write_workspace_source,
)
from simple_module_cli.uv_workspace import is_workspace_member

__all__ = ["ExecRunner", "add_module", "post_install", "run_add"]

Expand Down Expand Up @@ -137,18 +140,46 @@ def _add_pypi(doc, pyproject: Path, raw: str, no_sync: bool, exec_runner: ExecRu
return [name]


def _resolve_local_path(project_dir: Path, spec_path: Path) -> tuple[Path, str]:
"""Resolve a local-path spec to ``(directory, text written to pyproject)``.

A relative path is resolved against the ``--path`` project (the pyproject
being edited, which is what ``[tool.uv.sources]`` paths are relative to).
If nothing exists there but the path exists relative to the cwd, the cwd
one is used and the written path is re-expressed relative to the project.
"""
if spec_path.is_absolute():
return spec_path, str(spec_path)
root = project_dir / spec_path
if not root.exists() and (Path.cwd() / spec_path).exists():
root = Path.cwd() / spec_path
return root, os.path.relpath(root.resolve(), project_dir.resolve())
return root, str(spec_path)


def _add_path(
doc, pyproject: Path, spec_path: Path, no_sync: bool, exec_runner: ExecRunner
) -> list[str]:
root = spec_path if spec_path.is_absolute() else pyproject.parent / spec_path
root, written = _resolve_local_path(pyproject.parent, spec_path)
found = [m for m in scan_modules(root) if m.subdirectory is None]
if not found:
raise _fail(f"{root} has no [project.entry-points.simple_module] entry point at its root")
raise _fail(
f"{root} has no [project.entry-points.simple_module] entry point at its root "
f"(relative paths resolve against the --path project, {pyproject.parent}, "
"falling back to the current directory)"
)
mod = found[0]
write_dependency(doc, mod.dist_name, derive_range(mod.version))
write_path_source(doc, mod.dist_name, path=str(spec_path))
if is_workspace_member(pyproject.parent, root):
# uv refuses a path source for a workspace member; the scaffold's own
# sample module is wired the same way (unpinned + workspace = true).
write_dependency(doc, mod.dist_name, "")
write_workspace_source(doc, mod.dist_name)
typer.echo(f"Added {mod.dist_name} (uv workspace member {written}).")
else:
write_dependency(doc, mod.dist_name, derive_range(mod.version))
write_path_source(doc, mod.dist_name, path=written)
typer.echo(f"Added {mod.dist_name} (editable path {written}).")
save_pyproject(pyproject, doc)
typer.echo(f"Added {mod.dist_name} (editable path {spec_path}).")
if not no_sync:
post_install(
pyproject.parent,
Expand Down
41 changes: 30 additions & 11 deletions framework/cli/simple_module_cli/app_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
from typing import Any

from simple_module_cli._env import set_env_key
from simple_module_cli.case import to_kebab_case, to_pascal_case
from simple_module_cli._local_env import comment_out_env_key, write_local_env
from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case
from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
from simple_module_cli.docker_assets import scaffold_docker_assets
from simple_module_cli.recipes import RECIPES, ScaffoldCtx
Expand Down Expand Up @@ -52,6 +53,8 @@
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-playwright>=0.7.2",
# Floor: unpinned, uv backtracks playwright to 1.18 (GH #445).
"playwright>=1.45",
"ruff>=0.8",
"ty>=0.0.29",
]
Expand Down Expand Up @@ -150,19 +153,33 @@ def create_app_project(
py_deps.append(_SAMPLE_MODULE_PKG)
workspace_sources.append(_SAMPLE_MODULE_PKG)

env_path = target / ".env.example"
env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32))
# Generated secrets go to the gitignored .env, never the committed
# .env.example (GH #441) — see write_local_env below.
secret_keys = ["SM_SECRET_KEY"]
if "users" in resolved:
# UsersSettings refuses its placeholder token secrets when
# SM_ENVIRONMENT=production — the mode the compose containers run
# in — so generate real ones, same as SM_SECRET_KEY above.
for key in (
# in — so generate real ones, same as SM_SECRET_KEY.
secret_keys += [
"SM_USERS_RESET_PASSWORD_TOKEN_SECRET",
"SM_USERS_VERIFICATION_TOKEN_SECRET",
):
env_text = set_env_key(env_text, key, _secrets.token_urlsafe(32))
env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat))
]
db_name = to_snake_case(name)
env_path = target / ".env.example"
env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
for key in secret_keys:
env_text = comment_out_env_key(env_text, key)
local_overrides: dict[str, str] = {}
if db == "postgres":
# The compose `postgres` service takes its password from .env, and
# the dev SM_DATABASE_URL must match it (GH #444).
env_text = set_env_key(env_text, "POSTGRES_PASSWORD", "")
password = _secrets.token_urlsafe(24)
local_overrides = {
"POSTGRES_PASSWORD": password,
"SM_DATABASE_URL": _db_url(db, db_name, flat=flat, password=password),
}
env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, db_name, flat=flat))
env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
env_path.write_text(env_text, encoding="utf-8")

Expand Down Expand Up @@ -198,6 +215,8 @@ def create_app_project(
RECIPES[recipe_key].apply(target, ctx)

scaffold_docker_assets(target, ctx, flat=flat)
# Last, so .env picks up every key the recipes added to .env.example.
write_local_env(target, secret_keys=secret_keys, overrides=local_overrides)

return host_dir, preserved

Expand Down Expand Up @@ -234,9 +253,9 @@ def _seed_static_dist_placeholder(static_dist: Path) -> None:
(static_dist / ".gitkeep").touch()


def _db_url(db: str, slug: str, *, flat: bool) -> str:
def _db_url(db: str, db_name: str, *, flat: bool, password: str = "<POSTGRES_PASSWORD>") -> str:
if db == "postgres":
return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}"
return f"postgresql+asyncpg://postgres:{password}@localhost:5432/{db_name}"
# Workspace mode keeps the SQLite file next to host/'s alembic.ini so
# `cd host && uvicorn` and `cd host && alembic` resolve the same path.
return "sqlite+aiosqlite:///./app.db" if flat else "sqlite+aiosqlite:///./host/app.db"
Expand Down
22 changes: 22 additions & 0 deletions framework/cli/simple_module_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

import os
from pathlib import Path
from typing import Annotated

Expand All @@ -30,6 +31,7 @@
from simple_module_cli.scaffolding import is_inside_existing_repo, resolve_framework_version
from simple_module_cli.skills_cmd import app as skills_app
from simple_module_cli.update_cmd import update_modules
from simple_module_cli.uv_workspace import find_workspace_root, is_workspace_member

app = typer.Typer(
help="SimpleModule developer CLI.",
Expand Down Expand Up @@ -103,6 +105,25 @@ def create_host(
typer.echo(" python main.py")


def _echo_workspace_hint(target: Path) -> None:
"""Tell the user how to wire a workspace-member module into the host (GH #442)."""
root = find_workspace_root(target.parent)
if root is None:
return
host = root / "host"
if not (host / "pyproject.toml").is_file() or not is_workspace_member(host, target):
return
rel = os.path.relpath(target.resolve(), host.resolve())
host_arg = os.path.relpath(host.resolve(), Path.cwd())
if host_arg.startswith(".."):
host_arg = str(host.resolve())
typer.echo(
f"\nThis module is a uv workspace member. Wire it into the host with:\n"
f" smpy add {rel} --path {host_arg}"
" # adds it as { workspace = true }"
)


@app.command("create-module")
def create_module(
name: Annotated[str, typer.Argument(help="Module name (any case).")],
Expand Down Expand Up @@ -149,6 +170,7 @@ def create_module(
"Skipped .github/ workflows: this module is inside an existing repo, "
"where nested workflows never run. Use --standalone to emit them."
)
_echo_workspace_hint(target)
typer.echo("\nNext steps:")
typer.echo(f" cd {target}")
typer.echo(" uv sync --extra dev")
Expand Down
5 changes: 3 additions & 2 deletions framework/cli/simple_module_cli/docker_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import shutil
from pathlib import Path

from simple_module_cli.case import to_kebab_case
from simple_module_cli.case import to_snake_case
from simple_module_cli.recipes import ScaffoldCtx

__all__ = ["scaffold_docker_assets"]
Expand Down Expand Up @@ -70,7 +70,8 @@ def scaffold_docker_assets(target: Path, ctx: ScaffoldCtx, *, flat: bool = False
# compiles to DEFAULT 0 on SQLite, which Postgres rejects), so pointing
# a sqlite-scaffolded app at a Postgres container fails on first boot.
db = "postgres" if ctx.db == "postgres" else "sqlite"
pg_db = to_kebab_case(ctx.name)
# snake_case: a kebab name needs quoting in every psql call (GH #443).
pg_db = to_snake_case(ctx.name)
with_tasks = "background_tasks" in ctx.selected
compose = (
_read(templates / f"compose-base-{db}.yml.tpl")
Expand Down
1 change: 1 addition & 0 deletions framework/cli/simple_module_cli/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def new_project(
for path in preserved:
rel = path.relative_to(target) if path.is_relative_to(target) else path
typer.echo(f" {rel}")
typer.echo("\nGenerated secrets were written to .env (gitignored), not .env.example.")
typer.echo("\nNext steps:")
typer.echo(f" cd {target}")
if no_install:
Expand Down
Loading
Loading