diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ad8f892..e01c914d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,7 @@ jobs: fail-fast: false matrix: package: + - simple_module_cli - simple_module_core - simple_module_db - simple_module_hosting diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e620e1a..123291a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Initial public release. All 14 Python packages publish to PyPI and all 3 JS pack ### Added -- `simple-module new ` / `sm new ` CLI generator scaffolding a working app with `users + dashboard + permissions` pre-wired. +- `sm new ` CLI generator (shipped via the `simple_module_cli` PyPI distribution) scaffolding a working app with `users + dashboard + permissions` pre-wired. - PyPI Trusted Publishing workflow (`.github/workflows/release.yml`) for zero-secret releases. - npm Trusted Publishing for all three JS packages. diff --git a/Makefile b/Makefile index 3ee7da3e..1ab28bc4 100644 --- a/Makefile +++ b/Makefile @@ -25,12 +25,12 @@ dev-ui: # Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} from installed modules. gen-pages: - uv run --project host sm gen-pages --host-dir=host/client_app + uv run --project host sm host gen-pages --host-dir=host/client_app # Install JS deps declared by installed modules into host/client_app/node_modules. # Wheel-installed modules need this; in-repo workspace modules do not. sync-module-deps: - uv run --project host sm sync-js-deps --host-client-app=host/client_app + uv run --project host sm host sync-js-deps --host-client-app=host/client_app # Build build: diff --git a/README.md b/README.md index 6ab80041..1214d1f0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A modular-monolith framework for Python. Each feature lives in its own self-cont If you want to **build an app on simple_module**, not hack on the framework itself: ```bash -uvx --from simple_module_hosting simple-module new my-app +uvx --from simple_module_cli sm new my-app cd my-app make dev ``` @@ -119,7 +119,7 @@ Power users can still override the following bootstrap knobs via env if needed: All module-level settings — users, SMTP, Celery broker, file storage backend, etc. — live in the admin UI. After upgrading an existing deployment, run once: ```bash -uv run sm-settings import-from-env +uv run sm settings import-from-env ``` to seed DB overrides from the current `SM_*` environment. @@ -135,7 +135,7 @@ See `framework-conventions.md` for the settings-per-module convention. Either use the CLI: ```bash -uv run sm-users create-admin --email admin@example.com --password changeme +uv run sm users create-admin --email admin@example.com --password changeme ``` Or let the app bootstrap it automatically on first boot by setting env vars **before** running `make migrate && make dev`: diff --git a/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md b/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md new file mode 100644 index 00000000..ea0bbc4d --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md @@ -0,0 +1,1392 @@ +# CLI: project setup with modules and background jobs — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade `sm new` so it scaffolds a SimpleModule project with any chosen subset of modules (presets or custom) and lands a runnable Celery worker + beat + Redis stack when `background_tasks` is selected — no manual editing required. + +**Architecture:** Replace the single-file `framework/hosting/simple_module_hosting/cli.py` with a `cli/` package containing a hardcoded module **catalog** (with transitive-dep resolution), an interactive **wizard**, and per-module **recipes** that perform post-scaffold actions. The `background_tasks` recipe writes `scripts/run_worker.py`, appends Make targets, and emits a `docker-compose.yml` + `worker.Dockerfile`. + +**Tech Stack:** Python 3.12, `click` (existing dep), Click `CliRunner` for tests, `pytest`. No new runtime deps. Templates ship as package data under `simple_module_hosting/templates/host/_optional/background_tasks/`. + +**Spec:** `docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md` + +--- + +## File Structure (created or modified) + +| Path | Role | +|---|---| +| `framework/hosting/simple_module_hosting/cli.py` | **Deleted** — replaced by package below | +| `framework/hosting/simple_module_hosting/cli/__init__.py` | Click group; existing commands `create-host`, `create-module`, `gen-pages`, `sync-js-deps` live here. Re-exports `main`. | +| `framework/hosting/simple_module_hosting/cli/catalog.py` | `ModuleEntry`, `CATALOG`, `PRESETS`, `expand_deps()` | +| `framework/hosting/simple_module_hosting/cli/wizard.py` | `run_wizard(default_db, default_tenancy)` returning `(db, tenancy, selected)` | +| `framework/hosting/simple_module_hosting/cli/recipes.py` | `Recipe` protocol, `ScaffoldCtx`, `RECIPES` dict, `BackgroundTasksRecipe` | +| `framework/hosting/simple_module_hosting/cli/new.py` | The upgraded `new_project` command | +| `framework/hosting/simple_module_hosting/scaffolding.py` | `create_app_project` gains `selected: Sequence[str] \| None` param | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py` | Static template (no `.tpl` suffix; verbatim copy by recipe — bypass `_apply_template_files`) | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml` | Compose stack: redis + worker + beat | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet` | `worker` / `beat` / `worker-docker` targets | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile` | Celery image for worker + beat | +| `framework/hosting/tests/test_cli_catalog.py` | New | +| `framework/hosting/tests/test_cli_wizard.py` | New | +| `framework/hosting/tests/test_cli_recipes.py` | New | +| `framework/hosting/tests/test_cli_new.py` | Extended with preset + recipe tests | + +`framework/hosting/pyproject.toml` does **not** change — the entry point `sm = "simple_module_hosting.cli:main"` resolves the same after `cli.py` becomes `cli/__init__.py` because `main` is re-exported. + +--- + +## Task 1: Convert `cli.py` → `cli/` package (no behavior change) + +**Files:** +- Delete: `framework/hosting/simple_module_hosting/cli.py` +- Create: `framework/hosting/simple_module_hosting/cli/__init__.py` +- Test: `framework/hosting/tests/test_cli_new.py` (existing — must keep passing) + +This task is a pure refactor. We move every existing command into `cli/__init__.py` verbatim so the test suite continues to pass before any behavior change. + +- [ ] **Step 1: Run existing CLI tests baseline** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 2: Create the package directory** + +```bash +mkdir -p framework/hosting/simple_module_hosting/cli +git mv framework/hosting/simple_module_hosting/cli.py framework/hosting/simple_module_hosting/cli/__init__.py +``` + +- [ ] **Step 3: Run tests to confirm move is transparent** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py -v` +Expected: PASS (3 tests). Python imports `cli/__init__.py` for `simple_module_hosting.cli` exactly the same as `cli.py`, so `main` is still findable. + +- [ ] **Step 4: Verify `sm` console script still resolves** + +Run: `uv run sm --help` +Expected: Lists `new`, `create-host`, `create-module`, `gen-pages`, `sync-js-deps` — same as before. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/__init__.py +git rm framework/hosting/simple_module_hosting/cli.py +git commit -m "refactor(cli): move cli.py to cli/__init__.py (no behavior change) + +Preparing the file for split into a package — catalog, wizard, recipes, +new will land in dedicated modules. Console-script entry point unchanged." +``` + +--- + +## Task 2: Build the module catalog with transitive dep resolution + +**Files:** +- Create: `framework/hosting/simple_module_hosting/cli/catalog.py` +- Create: `framework/hosting/tests/test_cli_catalog.py` + +The catalog is a pure data + one pure function (`expand_deps`). TDD it. + +- [ ] **Step 1: Write failing tests for `expand_deps`** + +```python +# framework/hosting/tests/test_cli_catalog.py +"""Tests for the module catalog and dependency expansion.""" + +from __future__ import annotations + +import pytest +from simple_module_hosting.cli.catalog import ( + CATALOG, + PRESETS, + ModuleEntry, + expand_deps, +) + + +def test_catalog_keys_match_entry_names() -> None: + for key, entry in CATALOG.items(): + assert key == entry.name, f"catalog key {key!r} != entry.name {entry.name!r}" + + +def test_every_requires_value_is_a_known_catalog_key() -> None: + for entry in CATALOG.values(): + for required in entry.requires: + assert required in CATALOG, ( + f"{entry.name} requires unknown module {required!r}" + ) + + +def test_presets_only_reference_known_modules() -> None: + for name, mods in PRESETS.items(): + for m in mods: + assert m in CATALOG, f"preset {name!r} references unknown module {m!r}" + + +def test_expand_deps_returns_input_when_no_requires() -> None: + resolved, added = expand_deps(["auth"]) + assert resolved == ["auth"] + assert added == [] + + +def test_expand_deps_pulls_in_transitive_dep() -> None: + # users requires auth (per ModuleMeta.depends_on) + resolved, added = expand_deps(["users"]) + assert set(resolved) == {"auth", "users"} + assert added == [("auth", "users")] + + +def test_expand_deps_pulls_in_chain() -> None: + # datasets -> file_storage -> settings AND datasets -> background_tasks -> users -> auth + resolved, added = expand_deps(["datasets"]) + assert set(resolved) == { + "datasets", + "file_storage", + "settings", + "background_tasks", + "users", + "auth", + } + # Every added pair points to a real requirer in the input or already-added set. + added_names = {a for a, _ in added} + assert added_names == {"file_storage", "settings", "background_tasks", "users", "auth"} + + +def test_expand_deps_idempotent_when_input_already_complete() -> None: + resolved1, _ = expand_deps(["users"]) + resolved2, added2 = expand_deps(resolved1) + assert sorted(resolved1) == sorted(resolved2) + assert added2 == [] + + +def test_expand_deps_unknown_name_raises_with_available_list() -> None: + with pytest.raises(KeyError) as exc: + expand_deps(["does_not_exist"]) + msg = str(exc.value) + assert "does_not_exist" in msg + assert "auth" in msg # one of the available names in the message + + +def test_expand_deps_preserves_load_order_dep_before_dependent() -> None: + """The resolved list must be in topo order: a module's deps appear before it.""" + resolved, _ = expand_deps(["dashboard"]) + for i, name in enumerate(resolved): + for required in CATALOG[name].requires: + assert resolved.index(required) < i, ( + f"{required} must appear before {name} in {resolved}" + ) + + +def test_module_entry_is_frozen() -> None: + entry = ModuleEntry(name="x", package="simple_module_x", display="X") + with pytest.raises(Exception): # FrozenInstanceError or AttributeError + entry.name = "y" # type: ignore[misc] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_catalog.py -v` +Expected: All tests fail with `ModuleNotFoundError: No module named 'simple_module_hosting.cli.catalog'`. + +- [ ] **Step 3: Implement `cli/catalog.py`** + +```python +# framework/hosting/simple_module_hosting/cli/catalog.py +"""Hardcoded catalog of installable SimpleModule modules. + +Each :class:`ModuleEntry` declares the PyPI package name, a human display +name, transitive `requires` (other catalog keys), and an optional `recipe` +key for post-scaffold actions handled by :mod:`.recipes`. + +`expand_deps` takes a user-selected subset and returns a topologically +ordered superset including every transitive requirement, plus the list +of `(added, required_by)` pairs for printing back to the user. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +__all__ = ["CATALOG", "PRESETS", "ModuleEntry", "expand_deps"] + + +@dataclass(frozen=True) +class ModuleEntry: + name: str + package: str + display: str + requires: tuple[str, ...] = field(default_factory=tuple) + recipe: str | None = None + + +# Keys are snake_case; values mirror each module's real +# ``ModuleMeta.depends_on`` (transcribed to catalog keys). +CATALOG: dict[str, ModuleEntry] = { + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), + "permissions": ModuleEntry("permissions", "simple_module_permissions", "Permissions", requires=("auth", "users")), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "dashboard": ModuleEntry("dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products")), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), + "file_storage": ModuleEntry("file_storage", "simple_module_file_storage", "File Storage", requires=("settings",)), + "background_tasks": ModuleEntry("background_tasks", "simple_module_background_tasks", "Background Tasks", requires=("users",), recipe="background_tasks"), + "datasets": ModuleEntry("datasets", "simple_module_datasets", "Datasets", requires=("file_storage", "background_tasks")), +} + + +PRESETS: dict[str, tuple[str, ...]] = { + "minimal": ("users",), + "standard": ("users", "dashboard", "permissions"), + "full": tuple(CATALOG), +} + + +def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]: + """Return (topo-ordered resolved list, [(added, required_by), ...]). + + Raises ``KeyError`` if any input name is not in the catalog. The + error message lists the available catalog keys. + """ + selected_list = list(selected) + for name in selected_list: + if name not in CATALOG: + available = ", ".join(sorted(CATALOG)) + raise KeyError( + f"unknown module: {name!r}; available: {available}" + ) + + explicit = set(selected_list) + resolved: list[str] = [] + in_resolved: set[str] = set() + added: list[tuple[str, str]] = [] + + def _visit(name: str, required_by: str | None) -> None: + if name in in_resolved: + return + for dep in CATALOG[name].requires: + _visit(dep, required_by=name) + resolved.append(name) + in_resolved.add(name) + if required_by is not None and name not in explicit: + added.append((name, required_by)) + + for name in selected_list: + _visit(name, required_by=None) + return resolved, added +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest framework/hosting/tests/test_cli_catalog.py -v` +Expected: All 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/catalog.py framework/hosting/tests/test_cli_catalog.py +git commit -m "feat(cli): module catalog with transitive dep expansion + +Adds CATALOG, PRESETS, and expand_deps() — pure data + one pure +function. Will be wired into 'sm new' in a follow-up." +``` + +--- + +## Task 3: Implement the interactive wizard + +**Files:** +- Create: `framework/hosting/simple_module_hosting/cli/wizard.py` +- Create: `framework/hosting/tests/test_cli_wizard.py` + +Wizard owns the prompt sequence: db → tenancy → preset (or custom checkbox loop) → confirmation. Returns the resolved tuple `(db, tenancy, selected_topo_ordered)`. + +- [ ] **Step 1: Write failing tests for the wizard** + +```python +# framework/hosting/tests/test_cli_wizard.py +"""Tests for the `sm new` interactive wizard.""" + +from __future__ import annotations + +import click +from click.testing import CliRunner +from simple_module_hosting.cli.wizard import run_wizard + + +def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: + """Run the wizard with stdin pre-fed; return (db, tenancy, selected, output).""" + + captured: dict = {} + + @click.command() + def wrapper() -> None: + db, tenancy, selected = run_wizard(default_db="sqlite", default_tenancy=False) + captured["db"] = db + captured["tenancy"] = tenancy + captured["selected"] = selected + + runner = CliRunner() + result = runner.invoke(wrapper, input="\n".join(answers) + "\n") + assert result.exit_code == 0, result.output + return captured["db"], captured["tenancy"], captured["selected"], result.output + + +def test_wizard_standard_preset_default_path() -> None: + # Answers: db=, tenancy=, preset=, confirm= + db, tenancy, selected, out = _drive(["", "", "", ""]) + assert db == "sqlite" + assert tenancy is False + assert "users" in selected and "dashboard" in selected and "permissions" in selected + # auth auto-added because users/permissions require it + assert "auth" in selected + assert "Added auth (required by" in out + + +def test_wizard_postgres_with_tenancy() -> None: + db, tenancy, _selected, _out = _drive(["postgres", "y", "", ""]) + assert db == "postgres" + assert tenancy is True + + +def test_wizard_minimal_preset() -> None: + _, _, selected, _ = _drive(["", "", "1", ""]) + # minimal = users; users requires auth + assert set(selected) == {"users", "auth"} + + +def test_wizard_full_preset_includes_background_tasks() -> None: + _, _, selected, _ = _drive(["", "", "3", ""]) + assert "background_tasks" in selected + assert "datasets" in selected + assert len(selected) >= 10 + + +def test_wizard_custom_picks_only_yes_answers() -> None: + # preset=4 (custom). Per-module loop walks CATALOG order: + # auth, users, permissions, products, dashboard, settings, feature_flags, + # file_storage, background_tasks, datasets — answer y to background_tasks only. + answers = ["", "", "4"] + ["n"] * 8 + ["y", "n", ""] + _, _, selected, out = _drive(answers) + # background_tasks pulls in users (its only require), which pulls in auth. + assert set(selected) == {"background_tasks", "users", "auth"} + assert "Added users (required by background_tasks)" in out + assert "Added auth (required by users)" in out + + +def test_wizard_aborts_on_confirm_no() -> None: + captured: dict = {} + + @click.command() + def wrapper() -> None: + try: + run_wizard(default_db="sqlite", default_tenancy=False) + except click.Abort: + captured["aborted"] = True + raise + + runner = CliRunner() + result = runner.invoke(wrapper, input="\n".join(["", "", "", "n"]) + "\n") + assert result.exit_code != 0 + assert captured.get("aborted") is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_wizard.py -v` +Expected: All 6 tests fail with `ModuleNotFoundError: No module named 'simple_module_hosting.cli.wizard'`. + +- [ ] **Step 3: Implement `cli/wizard.py`** + +```python +# framework/hosting/simple_module_hosting/cli/wizard.py +"""Interactive prompt sequence for `sm new`. + +Returns the user's choices as ``(db, tenancy, selected)`` where ``selected`` +is the topologically resolved module list (already includes transitive +requires). All prompts use ``click`` — no extra TUI dep. +""" + +from __future__ import annotations + +import click + +from .catalog import CATALOG, PRESETS, expand_deps + +__all__ = ["run_wizard"] + + +_PRESET_CHOICES = ("minimal", "standard", "full", "custom") + + +def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: + db = click.prompt( + "Database backend", + default=default_db, + type=click.Choice(["sqlite", "postgres"]), + ) + tenancy = click.confirm("Enable multi-tenancy?", default=default_tenancy) + + click.echo("\nPreset:") + click.echo(" [1] minimal — users only") + click.echo(" [2] standard — users, dashboard, permissions (default)") + click.echo(" [3] full — every module") + click.echo(" [4] custom — pick modules one by one") + choice = click.prompt( + "Choose", + default="2", + type=click.Choice(["1", "2", "3", "4"]), + show_choices=False, + ) + preset_name = _PRESET_CHOICES[int(choice) - 1] + + if preset_name == "custom": + picked = [ + name for name in CATALOG + if click.confirm(f"Include {CATALOG[name].display}?", default=False) + ] + else: + picked = list(PRESETS[preset_name]) + + resolved, added = expand_deps(picked) + for name, required_by in added: + click.echo(f"Added {name} (required by {required_by})") + click.echo(f"Selected modules: {', '.join(resolved)}") + + if not click.confirm("Proceed?", default=True): + raise click.Abort() + return db, tenancy, resolved +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest framework/hosting/tests/test_cli_wizard.py -v` +Expected: All 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/wizard.py framework/hosting/tests/test_cli_wizard.py +git commit -m "feat(cli): interactive wizard for sm new + +db -> tenancy -> preset (or custom checkbox loop) -> confirm. Auto-adds +required deps with a printed note. No new TUI dependency." +``` + +--- + +## Task 4: Add `background_tasks` recipe + templates + +**Files:** +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py` +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml` +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet` +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile` +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py:58-72` — make `_iter_template_files` skip `_optional/` +- Create: `framework/hosting/simple_module_hosting/cli/recipes.py` +- Create: `framework/hosting/tests/test_cli_recipes.py` + +### Step group A — templates + +- [ ] **Step 1: Write the worker run script template** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py`: + +```python +"""Entry point for the Celery worker and beat services. + +Both the web process and the worker go through the same +``background_tasks.celery_app.build_celery`` factory so the broker +config, autodiscovered tasks, and signal handlers stay in lockstep. + +Run locally: + uv run celery -A scripts.run_worker:celery worker -l info + uv run celery -A scripts.run_worker:celery beat -l info +""" + +from __future__ import annotations + +from background_tasks.celery_app import build_celery +from background_tasks.settings import BackgroundTasksSettings + +celery = build_celery(BackgroundTasksSettings()) +``` + +- [ ] **Step 2: Write the docker-compose template** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml`: + +```yaml +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + worker: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "worker" + - "-l" + - "info" + - "--concurrency=4" + + beat: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + worker: + condition: service_started + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "beat" + - "-l" + - "info" + +volumes: + redisdata: +``` + +- [ ] **Step 3: Write the Makefile snippet** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet`: + +```make +# --- background_tasks ---------------------------------------------------- +.PHONY: worker beat worker-docker + +worker: ## Run a Celery worker locally against $(SM_BG_TASKS_BROKER_URL) + uv run celery -A scripts.run_worker:celery worker -l info + +beat: ## Run the Celery beat scheduler locally + uv run celery -A scripts.run_worker:celery beat -l info + +worker-docker: ## Build + run the worker + beat services in docker + docker compose up --build worker beat +# --- end background_tasks ------------------------------------------------ +``` + +- [ ] **Step 4: Write the worker Dockerfile template** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile`: + +```dockerfile +# Celery worker image for the BackgroundTasks module. +# Serves both the worker and beat services in docker-compose — they +# differ only by command. + +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_SYSTEM_PYTHON=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir uv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY scripts/ scripts/ +COPY client_app/ client_app/ + +RUN uv sync --frozen --no-dev + +RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin worker \ + && chown -R worker:worker /app +USER worker + +ENV CELERY_APP=scripts.run_worker:celery +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD uv run celery -A $CELERY_APP inspect ping -d celery@$HOSTNAME || exit 1 + +CMD ["uv", "run", "celery", "-A", "scripts.run_worker:celery", "worker", "-l", "info"] +``` + +- [ ] **Step 5: Update `_iter_template_files` to skip `_optional/`** + +In `framework/hosting/simple_module_hosting/scaffolding.py`, replace the `_iter_template_files` function (lines ~58-62): + +```python +def _iter_template_files(template_root: Path): + """Yield every file under ``template_root``, preserving relative paths. + + Skips any path under an ``_optional/`` segment — those are recipe-managed + templates consumed by :mod:`simple_module_hosting.cli.recipes`, not by + the default scaffolding pass. + """ + for path in template_root.rglob("*"): + if not path.is_file(): + continue + if "_optional" in path.relative_to(template_root).parts: + continue + yield path +``` + +- [ ] **Step 6: Run existing scaffolding tests to confirm `_optional/` is excluded** + +Run: `uv run pytest framework/hosting/tests/test_scaffolding_host.py framework/hosting/tests/test_cli_new.py -v` +Expected: All existing tests still pass — the `_optional/` files do **not** leak into the scaffolded host. + +### Step group B — recipe + +- [ ] **Step 7: Write failing tests for `BackgroundTasksRecipe`** + +```python +# framework/hosting/tests/test_cli_recipes.py +"""Tests for per-module post-scaffold recipes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from simple_module_hosting.cli.recipes import ( + RECIPES, + BackgroundTasksRecipe, + ScaffoldCtx, +) +from simple_module_hosting.scaffolding import create_host + + +def _scaffold_minimal_host(target: Path) -> None: + create_host(target, name="demo", modules=["Users"]) + + +def test_background_tasks_recipe_registered() -> None: + assert "background_tasks" in RECIPES + assert isinstance(RECIPES["background_tasks"], BackgroundTasksRecipe) + + +def test_recipe_writes_run_worker_script(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + script = tmp_path / "scripts" / "run_worker.py" + assert script.is_file() + text = script.read_text() + assert "from background_tasks.celery_app import build_celery" in text + assert "celery = build_celery(BackgroundTasksSettings())" in text + + +def test_recipe_writes_compose_with_redis_worker_beat(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + compose = (tmp_path / "docker-compose.yml").read_text() + assert "redis:" in compose + assert "worker:" in compose + assert "beat:" in compose + assert "scripts.run_worker:celery" in compose + + +def test_recipe_writes_worker_dockerfile(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + dockerfile = (tmp_path / "docker" / "worker.Dockerfile").read_text() + assert "FROM python:3.12-slim" in dockerfile + assert "scripts.run_worker:celery" in dockerfile + + +def test_recipe_appends_makefile_targets(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + makefile = (tmp_path / "Makefile").read_text() + assert "worker:" in makefile + assert "beat:" in makefile + assert "worker-docker:" in makefile + + +def test_recipe_sets_broker_url_env_var(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + env_text = (tmp_path / ".env.example").read_text() + assert "SM_BG_TASKS_BROKER_URL=redis://redis:6379/0" in env_text + + +def test_recipe_makefile_snippet_idempotent(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + ctx = ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + BackgroundTasksRecipe().apply(tmp_path, ctx) + first = (tmp_path / "Makefile").read_text() + # Re-running on the same target must error or be idempotent — collisions + # on run_worker.py / compose / Dockerfile must raise. + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply(tmp_path, ctx) + assert (tmp_path / "Makefile").read_text() == first + + +def test_recipe_errors_on_existing_run_worker(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + (tmp_path / "scripts").mkdir(exist_ok=True) + (tmp_path / "scripts" / "run_worker.py").write_text("# user-authored\n") + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply( + tmp_path, + ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)), + ) +``` + +- [ ] **Step 8: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_recipes.py -v` +Expected: All tests fail with `ModuleNotFoundError: No module named 'simple_module_hosting.cli.recipes'`. + +- [ ] **Step 9: Implement `cli/recipes.py`** + +```python +# framework/hosting/simple_module_hosting/cli/recipes.py +"""Per-module post-scaffold recipes. + +A recipe is invoked by ``sm new`` after the base host scaffold lands. It +performs module-specific actions (write helper scripts, append Make +targets, drop a docker-compose stack). The framework layer is kept free +of devex concerns — recipes know about Makefiles and compose, framework +scaffolding does not. +""" + +from __future__ import annotations + +import importlib.resources +import shutil +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +__all__ = [ + "BackgroundTasksRecipe", + "RECIPES", + "Recipe", + "ScaffoldCtx", +] + +_OPTIONAL_PACKAGE = "simple_module_hosting.templates.host._optional" +_BG_BROKER_ENV_KEY = "SM_BG_TASKS_BROKER_URL" +_BG_BROKER_DEFAULT = "redis://redis:6379/0" +_MAKEFILE_MARKER = "# --- background_tasks --" + + +@dataclass(frozen=True) +class ScaffoldCtx: + name: str + db: str + tenancy: bool + selected: Sequence[str] + + +class Recipe(Protocol): + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... + + +def _optional_template_root(name: str) -> Path: + return Path(str(importlib.resources.files(_OPTIONAL_PACKAGE) / name)) + + +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" + + +class BackgroundTasksRecipe: + """Lays down run_worker.py + compose + Dockerfile + Make targets.""" + + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: + templates = _optional_template_root("background_tasks") + + run_worker_dest = target / "scripts" / "run_worker.py" + compose_dest = target / "docker-compose.yml" + dockerfile_dest = target / "docker" / "worker.Dockerfile" + + for path in (run_worker_dest, compose_dest, dockerfile_dest): + if path.exists(): + raise FileExistsError( + f"{path} already exists — refusing to clobber. " + "Remove the file or run `sm new` against an empty directory." + ) + + run_worker_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "run_worker.py", run_worker_dest) + + shutil.copy2(templates / "docker-compose.yml", compose_dest) + + dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest) + + env_path = target / ".env.example" + env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + env_path.write_text( + _set_env_key(env_text, _BG_BROKER_ENV_KEY, _BG_BROKER_DEFAULT), + encoding="utf-8", + ) + + makefile_path = target / "Makefile" + snippet = (templates / "Makefile.snippet").read_text(encoding="utf-8") + existing = makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else "" + if _MAKEFILE_MARKER not in existing: + sep = "" if existing.endswith("\n") or not existing else "\n" + makefile_path.write_text(existing + sep + snippet, encoding="utf-8") + + +RECIPES: dict[str, Recipe] = { + "background_tasks": BackgroundTasksRecipe(), +} +``` + +- [ ] **Step 10: Run tests to verify they pass** + +Run: `uv run pytest framework/hosting/tests/test_cli_recipes.py -v` +Expected: All 8 tests pass. + +- [ ] **Step 11: Commit** + +```bash +git add framework/hosting/simple_module_hosting/templates/host/_optional/ \ + framework/hosting/simple_module_hosting/scaffolding.py \ + framework/hosting/simple_module_hosting/cli/recipes.py \ + framework/hosting/tests/test_cli_recipes.py +git commit -m "feat(cli): background_tasks recipe + opt-in templates + +Recipes lay down post-scaffold artifacts (run_worker.py, docker-compose +with redis/worker/beat, worker.Dockerfile, Makefile targets, env var) +without touching framework scaffolding. Templates live under +templates/host/_optional/ and are skipped by the default copy walker." +``` + +--- + +## Task 5: Refactor `create_app_project` to accept a `selected=` list + +**Files:** +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py:198-268` +- Test: `framework/hosting/tests/test_cli_new.py` (add new test cases) + +`create_app_project` currently hardcodes `["users", "dashboard", "permissions"]` and a hardcoded `_APP_PY_DEPS`. Make both come from the catalog so the wizard / flags can drive it. + +- [ ] **Step 1: Add failing tests for the new `selected=` parameter** + +Append to `framework/hosting/tests/test_cli_new.py`: + +```python +def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=["users", "background_tasks"]) + + pyproject = (target / "pyproject.toml").read_text() + # background_tasks selected -> dep listed + assert "simple_module_background_tasks" in pyproject + # auth auto-added (users requires auth) + assert "simple_module_auth" in pyproject + # dashboard NOT requested -> NOT listed + assert "simple_module_dashboard" not in pyproject + + +def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=["background_tasks"]) + + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + assert (target / "docker" / "worker.Dockerfile").is_file() + makefile_text = (target / "Makefile").read_text() + assert "worker:" in makefile_text + + +def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False) + pyproject = (target / "pyproject.toml").read_text() + for required in ("simple_module_users", "simple_module_dashboard", "simple_module_permissions"): + assert required in pyproject +``` + +- [ ] **Step 2: Run new tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py::test_create_app_project_with_selected_kwarg framework/hosting/tests/test_cli_new.py::test_create_app_project_runs_recipe_for_background_tasks -v` +Expected: FAIL — `selected` is an unexpected kwarg. + +- [ ] **Step 3: Refactor `create_app_project`** + +Replace the function body in `framework/hosting/simple_module_hosting/scaffolding.py` (around lines 198-268). Drop the hardcoded `_APP_PY_DEPS` constant; build deps from the catalog. Add `selected: Sequence[str] | None = None`. + +```python +# Near the top of scaffolding.py (after existing imports), add: +from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps +from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx +``` + +(Keep these imports at module bottom-of-imports to avoid circulars — `cli/recipes.py` imports nothing from `scaffolding.py` at module scope.) + +Then replace `create_app_project`: + +```python +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> None: + """Greenfield ``simple-module new`` scaffold. + + Wraps :func:`create_host` with a chosen module list (defaults to the + 'standard' preset), generates a secret, picks a DB URL, rewrites the + generated package.json / pyproject.toml to pin exact framework + versions, and applies any matching post-scaffold recipes. + """ + if target.exists() and any(target.iterdir()): + raise FileExistsError( + f"Destination {target} already exists and is non-empty; " + "choose a new path or remove its contents first." + ) + + chosen = list(selected) if selected is not None else list(PRESETS["standard"]) + resolved, _added = expand_deps(chosen) + + # create_host expects display names (PascalCase) for the {{MODULE_DEPS}} template. + display_names = [CATALOG[m].display.replace(" ", "") for m in resolved] + create_host(target, name=name, modules=display_names) + + py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ + f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved + ] + + 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)) + env_text = _set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, _to_kebab_case(name))) + env_text = _set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false") + env_path.write_text(env_text, encoding="utf-8") + + pyproject = target / "pyproject.toml" + if pyproject.exists(): + text = pyproject.read_text(encoding="utf-8") + text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS) + pyproject.write_text(text, encoding="utf-8") + + pkg_path = target / "package.json" + if pkg_path.exists(): + data = _json.loads(pkg_path.read_text(encoding="utf-8")) + else: + data = {"name": _to_kebab_case(name), "private": True, "type": "module"} + data.setdefault("dependencies", {}).update(_APP_NPM_DEPS) + data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) + pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") + + ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) + for mod_name in resolved: + recipe_key = CATALOG[mod_name].recipe + if recipe_key is not None and recipe_key in RECIPES: + RECIPES[recipe_key].apply(target, ctx) +``` + +Delete the now-unused `_APP_PY_DEPS` constant. + +- [ ] **Step 4: Run all CLI/scaffolding tests** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py framework/hosting/tests/test_cli_catalog.py framework/hosting/tests/test_cli_wizard.py framework/hosting/tests/test_cli_recipes.py framework/hosting/tests/test_scaffolding_host.py -v` +Expected: All pass — including the three new tests from Step 1 and the back-compat test. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/scaffolding.py framework/hosting/tests/test_cli_new.py +git commit -m "feat(scaffolding): create_app_project accepts selected= module list + +Default 'standard' preset preserves existing behavior. Selected modules +drive both Python deps (from catalog) and post-scaffold recipes." +``` + +--- + +## Task 6: Wire `sm new` to use catalog + wizard + recipes + +**Files:** +- Create: `framework/hosting/simple_module_hosting/cli/new.py` +- Modify: `framework/hosting/simple_module_hosting/cli/__init__.py` — register `new_project` command from `new.py`, drop the old inline definition +- Test: `framework/hosting/tests/test_cli_new.py` — add flag-driven coverage + +- [ ] **Step 1: Write failing tests for the new flag interface** + +Append to `framework/hosting/tests/test_cli_new.py`: + +```python +def test_sm_new_with_preset_full_includes_background_tasks(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--preset", "full", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_background_tasks" in pyproject + + +def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + [ + "new", "demo", + "--yes", + "--preset", "minimal", + "--with", "background_tasks", + "--no-install", + "--dest", str(target), + ], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + # users (from minimal) + background_tasks + transitively auth + assert "simple_module_users" in pyproject + assert "simple_module_background_tasks" in pyproject + assert "simple_module_auth" in pyproject + + +def test_sm_new_unknown_with_module_errors(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--with", "does_not_exist", "--no-install", "--dest", str(target)], + ) + assert result.exit_code != 0 + assert "does_not_exist" in result.output + assert "available" in result.output.lower() + + +def test_sm_new_yes_with_no_flags_uses_standard_preset(tmp_path: Path) -> None: + """Back-compat: --yes alone keeps today's pre-wired set.""" + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + for required in ("simple_module_users", "simple_module_dashboard", "simple_module_permissions"): + assert required in pyproject + # background_tasks not in standard preset + assert "simple_module_background_tasks" not in pyproject + + +def test_sm_new_interactive_full_preset(tmp_path: Path) -> None: + """Wizard path: db, tenancy, preset=3 (full), proceed.""" + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--no-install", "--dest", str(target)], + input="\n".join(["", "", "3", ""]) + "\n", + ) + assert result.exit_code == 0, result.output + assert (target / "docker-compose.yml").is_file() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py -v` +Expected: New tests fail (current `new` command doesn't accept `--preset` / `--with`). + +- [ ] **Step 3: Create `cli/new.py`** + +```python +# framework/hosting/simple_module_hosting/cli/new.py +"""The upgraded `sm new` command. + +Combines flag-driven non-interactive use (`--preset` / `--with`) with the +interactive wizard. All paths converge on +:func:`simple_module_hosting.scaffolding.create_app_project` with a +resolved module list. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import click + +from simple_module_hosting.scaffolding import create_app_project + +from .catalog import PRESETS, expand_deps +from .wizard import run_wizard + +__all__ = ["new_project"] + + +@click.command("new") +@click.argument("name") +@click.option( + "--dest", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Destination directory. Defaults to ./.", +) +@click.option( + "--db", + type=click.Choice(["sqlite", "postgres"]), + default="sqlite", + show_default=True, + help="Database backend to configure in .env.example.", +) +@click.option( + "--tenancy/--no-tenancy", + default=False, + show_default=True, + help="Enable the multi-tenant middleware by default.", +) +@click.option( + "--preset", + type=click.Choice(["minimal", "standard", "full"]), + default=None, + help="Module preset. Mutually compatible with --with (union).", +) +@click.option( + "--with", + "extra", + default="", + help="Comma-separated extra module names to include (e.g. background_tasks,file_storage).", +) +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Skip interactive prompts; accept defaults.", +) +@click.option( + "--no-install", + is_flag=True, + default=False, + help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", +) +def new_project( + name: str, + dest: Path | None, + db: str, + tenancy: bool, + preset: str | None, + extra: str, + yes: bool, + no_install: bool, +) -> None: + """Scaffold a new SimpleModule app, optionally with background jobs.""" + target = dest or Path.cwd() / name + + extra_list = [m.strip() for m in extra.split(",") if m.strip()] + flag_driven = preset is not None or bool(extra_list) + + if yes or flag_driven: + chosen = list(PRESETS[preset or "standard"]) + extra_list + try: + resolved, added = expand_deps(chosen) + except KeyError as exc: + click.echo(f"ERROR: {exc}", err=True) + sys.exit(1) + for added_name, required_by in added: + click.echo(f"Added {added_name} (required by {required_by})") + else: + try: + db, tenancy, resolved = run_wizard(default_db=db, default_tenancy=tenancy) + except click.Abort: + click.echo("Aborted.", err=True) + sys.exit(1) + + try: + create_app_project(target, name=name, db=db, tenancy=tenancy, selected=resolved) + except FileExistsError as exc: + click.echo(f"ERROR: {exc}", err=True) + sys.exit(1) + + click.echo(f"Created app '{name}' at {target}") + click.echo(f"Modules: {', '.join(resolved)}") + click.echo("\nNext steps:") + click.echo(f" cd {target}") + if no_install: + click.echo(" uv sync") + click.echo(" npm install") + click.echo(" alembic upgrade head") + click.echo(" make dev") + if "background_tasks" in resolved: + click.echo(" docker compose up -d redis worker beat # background jobs") + return + + click.echo("Installing dependencies...") + for cmd in (["uv", "sync"], ["npm", "install"]): + result = subprocess.run(cmd, cwd=target, check=False) + if result.returncode != 0: + click.echo( + f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " + "finish setup manually.", + err=True, + ) + return + + subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) + click.echo("\nSetup complete. Run `make dev` in the new directory.") + if "background_tasks" in resolved: + click.echo("For background jobs, also run: docker compose up -d redis worker beat") +``` + +- [ ] **Step 4: Strip the old `new_project` from `cli/__init__.py` and import from `new.py`** + +In `framework/hosting/simple_module_hosting/cli/__init__.py`: + +1. Remove the entire existing `@main.command("new")`-decorated `new_project` function. +2. Add the import + registration at the bottom of the file (after `main` is defined): + +```python +from .new import new_project as _new_project +main.add_command(_new_project) +``` + +- [ ] **Step 5: Run the full CLI test suite** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py framework/hosting/tests/test_cli_catalog.py framework/hosting/tests/test_cli_wizard.py framework/hosting/tests/test_cli_recipes.py framework/hosting/tests/test_scaffolding_host.py -v` +Expected: All tests pass — both the existing (`--yes --db sqlite`) tests and the five new flag/wizard tests from Step 1. + +- [ ] **Step 6: Manual smoke check** + +Run: +```bash +TMP=$(mktemp -d) && uv run sm new demo --yes --preset full --no-install --dest "$TMP/demo" +ls "$TMP/demo/scripts/run_worker.py" "$TMP/demo/docker-compose.yml" "$TMP/demo/docker/worker.Dockerfile" +grep -E '^(worker|beat|worker-docker):' "$TMP/demo/Makefile" +grep SM_BG_TASKS_BROKER_URL "$TMP/demo/.env.example" +``` +Expected: every file/grep matches; no errors. + +- [ ] **Step 7: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/new.py \ + framework/hosting/simple_module_hosting/cli/__init__.py \ + framework/hosting/tests/test_cli_new.py +git commit -m "feat(cli): sm new with --preset, --with, and wizard + +Scaffolds a project with any chosen subset of modules. Selecting +background_tasks lands a runnable Celery worker + beat + Redis stack +via docker compose, host Make targets, and scripts/run_worker.py — no +manual editing required." +``` + +--- + +## Task 7: Lint, type-check, and final verification + +**Files:** none + +- [ ] **Step 1: Run the project lint suite** + +Run: `make lint` +Expected: PASS. `ty` and `ruff` are happy with the new package; per-file 300-line cap is not breached. + +- [ ] **Step 2: Run the full Python test suite** + +Run: `uv run pytest` +Expected: All tests pass — including the existing scaffolding/host tests, which must not regress. + +- [ ] **Step 3: Verify `make doctor` on a generated project** + +Run: +```bash +TMP=$(mktemp -d) && uv run sm new demo --yes --preset full --no-install --dest "$TMP/demo" +cd "$TMP/demo" && uv sync && uv run sm doctor 2>&1 || true +cd - +``` +Expected: Exits clean (no SM001/SM008/SM009 errors). SM010 may surface because the freshly-scaffolded project has no migration history yet — that's existing behavior. + +- [ ] **Step 4: Final commit if any cleanup applied** + +If lint or tests required follow-up edits: +```bash +git add -p +git commit -m "chore(cli): post-implementation cleanup" +``` + +Otherwise skip. + +--- + +## Self-Review (run after writing the plan) + +**Spec coverage:** Every section of the spec is mapped to a task — +- Catalog → Task 2 +- Wizard → Task 3 +- `sm new` flags → Task 6 +- Recipes + templates → Task 4 +- `create_app_project` refactor → Task 5 +- File-layout reorg into `cli/` package → Task 1 +- Tests for catalog / wizard / recipes / smoke → Tasks 2, 3, 4, 6 +- Lint / line-cap → Task 7 + +**No placeholders:** every code block is complete, every command shown. + +**Type consistency:** `ScaffoldCtx`, `Recipe`, `ModuleEntry`, `expand_deps` signatures are identical across catalog.py / recipes.py / new.py / scaffolding.py. `selected=` keyword is the same in `create_app_project` and `ScaffoldCtx`. + +**Out of scope:** third-party catalog extension; YAML merging; new TUI deps. diff --git a/docs/superpowers/plans/2026-04-26-standalone-cli-package.md b/docs/superpowers/plans/2026-04-26-standalone-cli-package.md new file mode 100644 index 00000000..dfd8ac20 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-standalone-cli-package.md @@ -0,0 +1,2044 @@ +# Standalone `simple-module` CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Carve the scaffolder out of `simple_module_hosting` into a new PyPI distribution `simple-module` whose only deps are `typer` + `tomlkit`. Single `sm` console script; plugin subcommands (`sm host gen-pages`, `sm users create-admin`, …) discovered via Python entry points. All `sm-*` sibling scripts go away. + +**Architecture:** New workspace member `framework/cli/` containing the importable package `simple_module`. Click → Typer 1:1 port at the decorator layer; logic, templates, and tests are copies. `simple_module_hosting` keeps only its runtime + a new `host_cli.py` Typer app registered as a plugin. Modules `users` and `settings` swap their `sm-*` console-script entries for `simple_module.cli_plugins` entry-point entries. + +**Tech Stack:** Python 3.12, `typer>=0.12`, `tomlkit>=0.13`, `pytest`, `typer.testing.CliRunner`, `importlib.metadata.entry_points`, hatchling, uv workspaces. + +**Spec:** [docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md](docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md) + +--- + +## File-structure summary + +| Path | Role | +|---|---| +| `framework/cli/pyproject.toml` | New distribution `simple-module`, deps: `typer`, `tomlkit`. | +| `framework/cli/simple_module/__init__.py` | Empty, marks the package. | +| `framework/cli/simple_module/_env.py` | `set_env_key` (moved from `simple_module_hosting/_env.py`). | +| `framework/cli/simple_module/case.py` | `_to_snake_case`, `_to_kebab_case`, `_to_pascal_case` (moved from `scaffolding.py`). | +| `framework/cli/simple_module/scaffolding.py` | `create_host`, `create_module`, template walker (moved). | +| `framework/cli/simple_module/app_project.py` | `create_app_project` + helpers (moved). | +| `framework/cli/simple_module/catalog.py` | `ModuleEntry`, `CATALOG`, `PRESETS`, `expand_deps` (moved). | +| `framework/cli/simple_module/wizard.py` | `run_wizard` (moved + Typer port). | +| `framework/cli/simple_module/recipes.py` | `Recipe`, `BackgroundTasksRecipe`, `RECIPES` (moved). | +| `framework/cli/simple_module/new.py` | `sm new` Typer command (moved + ported). | +| `framework/cli/simple_module/cli.py` | Root Typer app + `create-host` / `create-module` commands + plugin mount + `main`. | +| `framework/cli/simple_module/plugins.py` | Entry-point discovery + mounting. | +| `framework/cli/simple_module/templates/` | All template files (moved from hosting). | +| `framework/cli/tests/test_*.py` | Migrated CLI tests (`test_cli_catalog`, `test_cli_wizard`, `test_cli_recipes`, `test_cli_new`, `test_scaffolding_host`, `test_scaffolding_module`, plus new `test_plugin_discovery`, `test_no_framework_deps`). | +| `framework/hosting/simple_module_hosting/host_cli.py` | New: Typer app with `gen-pages` + `sync-js-deps`. | +| `framework/hosting/simple_module_hosting/cli/` | **Deleted** — entire package. | +| `framework/hosting/simple_module_hosting/scaffolding.py` | **Deleted**. | +| `framework/hosting/simple_module_hosting/app_project.py` | **Deleted**. | +| `framework/hosting/simple_module_hosting/_env.py` | **Deleted**. | +| `framework/hosting/simple_module_hosting/templates/` | **Deleted** (moved). | +| `framework/hosting/simple_module_hosting/manifest.py` | Stays — used by `host_cli.py`. | +| `framework/hosting/pyproject.toml` | Drops `sm`/`simple-module` scripts; adds `simple_module.cli_plugins` entry. | +| `modules/users/pyproject.toml` | Drops `sm-users` script; adds `simple_module.cli_plugins` entry. | +| `modules/settings/pyproject.toml` | Drops `sm-settings` script; adds `simple_module.cli_plugins` entry. | +| `modules/settings/settings/cli.py` | Rewritten as Typer app. | +| `Makefile` | `sm gen-pages` → `sm host gen-pages`; `sm sync-js-deps` → `sm host sync-js-deps`. | +| `pyproject.toml` (root) | Workspace `members = ["framework/*", …]` already covers `framework/cli`; verify ruff `extend-exclude` updated to point at the new templates path. | +| `README.md` | `sm-users` / `sm-settings` snippets updated to `sm users …` / `sm settings …`. | + +--- + +## Task 1: Bootstrap the `simple-module` distribution + +**Files:** +- Create: `framework/cli/pyproject.toml` +- Create: `framework/cli/README.md` +- Create: `framework/cli/LICENSE` (copy of root `LICENSE`) +- Create: `framework/cli/simple_module/__init__.py` (empty) +- Modify: `pyproject.toml` (root) — verify `framework/cli` is covered by `framework/*` workspace glob. + +- [ ] **Step 1: Create the distribution directory and minimal Python package** + +```bash +mkdir -p framework/cli/simple_module framework/cli/tests +touch framework/cli/simple_module/__init__.py +touch framework/cli/tests/__init__.py +``` + +- [ ] **Step 2: Write `framework/cli/pyproject.toml`** + +Create `framework/cli/pyproject.toml`: + +```toml +[project] +name = "simple-module" +version = "0.0.1" +description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "scaffolding", "cli", "fastapi", "modular-monolith"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "typer>=0.12", + "tomlkit>=0.13", +] + +[project.scripts] +sm = "simple_module.cli:main" +simple-module = "simple_module.cli:main" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" +Issues = "https://github.com/antosubash/simple_module_python/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["simple_module"] +``` + +- [ ] **Step 3: Stub `simple_module/cli.py` so the entry point resolves** + +Create `framework/cli/simple_module/cli.py`: + +```python +"""Root `sm` command — scaffolders + plugin mount. + +This file gets fleshed out in Task 5 (Typer port) and Task 6 (plugin +discovery). For now it exists only so the ``sm = simple_module.cli:main`` +console-script entry point resolves cleanly during workspace install. +""" + +from __future__ import annotations + + +def main() -> int: + raise SystemExit( + "simple-module CLI is being installed but its commands have not " + "been wired up yet. Re-install once Task 5 lands." + ) +``` + +- [ ] **Step 4: Copy the LICENSE and write a README** + +```bash +cp LICENSE framework/cli/LICENSE +``` + +Create `framework/cli/README.md`: + +````markdown +# simple-module + +Standalone scaffolder for the [SimpleModule framework](https://github.com/antosubash/simple_module_python). + +```bash +pip install simple-module # or: pipx install simple-module +sm new my-app # interactive wizard +sm new my-app --yes --preset full +``` + +Provides three built-in commands: `sm new`, `sm create-host`, `sm create-module`. + +When other framework packages are installed, they contribute additional subcommands via the `simple_module.cli_plugins` entry-point group: + +| Package | Commands | +|---|---| +| `simple_module_hosting` | `sm host gen-pages`, `sm host sync-js-deps` | +| `simple_module_users` | `sm users create-admin` | +| `simple_module_settings` | `sm settings import-from-env` | + +## License + +MIT — see [LICENSE](LICENSE). +```` + +- [ ] **Step 5: Confirm workspace + uv sync resolves** + +The root `pyproject.toml` declares `members = ["framework/*", "modules/*", "host"]`. The new `framework/cli/` is automatically picked up. + +Run: `uv sync --all-packages` +Expected: succeeds; the new `simple-module` distribution appears in `uv pip list`. Run `uv pip list 2>&1 | grep simple-module` and expect a row. + +- [ ] **Step 6: Verify the stub console script resolves** + +Run: `uv run sm --help 2>&1 | head -3` +Expected: prints the SystemExit message from Step 3 (proves the entry point + package import works). + +- [ ] **Step 7: Commit** + +```bash +git add framework/cli/ pyproject.toml +git commit -m "feat(cli): bootstrap simple-module distribution + +$(cat <<'EOF' +New workspace member framework/cli/ shipping the simple-module PyPI +distribution. Contains stub cli.py for the sm entry point; real +commands land in subsequent commits. +EOF +)" +``` + +--- + +## Task 2: Move `_env.py` and case helpers + +Two pure-utility modules with no Click/Typer surface, no template I/O. Move first to establish the import-rewrite pattern. + +**Files:** +- Create: `framework/cli/simple_module/_env.py` +- Create: `framework/cli/simple_module/case.py` +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py:148-164` (remove `_to_snake_case`, `_to_kebab_case`, `_to_pascal_case`). +- Delete: `framework/hosting/simple_module_hosting/_env.py` +- Modify: `framework/hosting/simple_module_hosting/app_project.py` (update import). +- Modify: `framework/hosting/simple_module_hosting/cli/recipes.py` (update import). + +- [ ] **Step 1: Create `framework/cli/simple_module/_env.py`** + +```python +"""Shared helpers for editing dotenv-style files at scaffold time.""" + +from __future__ import annotations + +__all__ = ["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" +``` + +- [ ] **Step 2: Create `framework/cli/simple_module/case.py`** + +```python +"""Identifier case-conversion helpers used by every scaffolder. + +Module/host names are accepted in any case style and normalized to the +three forms the templates need: snake_case (Python package + entry-point +key), kebab-case (PyPI slug), and PascalCase (display name in Meta). +""" + +from __future__ import annotations + +import re + +__all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"] + + +def to_snake_case(name: str) -> str: + """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'.""" + s = re.sub(r"(? str: + """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug).""" + return to_snake_case(name).replace("_", "-") + + +def to_pascal_case(name: str) -> str: + """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta).""" + snake = to_snake_case(name) + return "".join(part.capitalize() for part in snake.split("_") if part) +``` + +(Note: leading `_` removed — these are now public helpers under `simple_module.case`.) + +- [ ] **Step 3: Add `simple-module` as a workspace dep of `simple_module_hosting`** + +Edit `framework/hosting/pyproject.toml` — add to `dependencies`: + +```toml +dependencies = [ + "click>=8.1", + "fastapi>=0.115", + "fastapi-inertia>=1.0", + "httpx>=0.27", + "jinja2>=3.1", + "simple_module==0.0.1", # NEW + "simple_module_core==0.0.1", + "simple_module_db==0.0.1", + "starlette>=0.44", + "tomlkit>=0.13", + "uvicorn[standard]>=0.34", +] +``` + +And add to `[tool.uv.sources]`: + +```toml +[tool.uv.sources] +simple_module = { workspace = true } +simple_module_core = { workspace = true } +simple_module_db = { workspace = true } +``` + +(Keep this dep through the rest of the migration so `simple_module_hosting` can re-import freely from `simple_module`. It's removed in Task 9.) + +- [ ] **Step 4: Run `uv sync --all-packages`** + +Run: `uv sync --all-packages` +Expected: succeeds. + +- [ ] **Step 5: Update `simple_module_hosting/app_project.py` to import from `simple_module`** + +Replace the existing imports (currently at top of file + a local import inside `create_app_project`): + +```python +# At top of framework/hosting/simple_module_hosting/app_project.py +from simple_module._env import set_env_key +from simple_module.case import to_kebab_case, to_pascal_case +``` + +Inside `create_app_project`, drop the local import of `_to_kebab_case`, `_to_pascal_case`, `create_host` from `simple_module_hosting.scaffolding` — keep only the `create_host` local import (case helpers are now top-level): + +```python +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> None: + from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps + from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx + from simple_module_hosting.scaffolding import create_host + ... +``` + +Replace the two call sites: +- `_to_kebab_case(name)` → `to_kebab_case(name)` +- `_to_pascal_case(CATALOG[m].display)` → `to_pascal_case(CATALOG[m].display)` + +- [ ] **Step 6: Update `simple_module_hosting/cli/recipes.py` to import from `simple_module`** + +Replace `from simple_module_hosting._env import set_env_key` with: + +```python +from simple_module._env import set_env_key +``` + +- [ ] **Step 7: Delete the case helpers from `scaffolding.py`** + +Edit `framework/hosting/simple_module_hosting/scaffolding.py`. Remove the three definitions: + +```python +def _to_snake_case(name: str) -> str: ... +def _to_kebab_case(name: str) -> str: ... +def _to_pascal_case(name: str) -> str: ... +``` + +(They lived around lines 148-164; they're no longer needed.) `create_module` calls them; update it to import from `simple_module.case`: + +```python +# Top of scaffolding.py +from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case +``` + +Inside `create_module`, replace `_to_pascal_case(name)` → `to_pascal_case(name)`, `_to_kebab_case(name)` → `to_kebab_case(name)`, `_to_snake_case(name)` → `to_snake_case(name)`. + +- [ ] **Step 8: Delete `framework/hosting/simple_module_hosting/_env.py`** + +```bash +git rm framework/hosting/simple_module_hosting/_env.py +``` + +- [ ] **Step 9: Run the full hosting test suite** + +Run: `uv run pytest framework/hosting/ -q` +Expected: all 144 tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add framework/cli/ framework/hosting/ pyproject.toml +git commit -m "refactor(cli): move _env and case helpers to simple_module package + +$(cat <<'EOF' +Pure utilities; first pieces of the simple-module distribution. Hosting +keeps temporary workspace dep on simple-module so existing modules can +import from both during the migration. +EOF +)" +``` + +--- + +## Task 3: Move `scaffolding.py` (`create_host`, `create_module`) and templates + +**Files:** +- Create: `framework/cli/simple_module/scaffolding.py` +- Move: `framework/hosting/simple_module_hosting/templates/` → `framework/cli/simple_module/templates/` +- Modify: `framework/cli/pyproject.toml` (declare templates as package data via hatch). +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py` (becomes a re-export shim). +- Move: `framework/hosting/tests/test_scaffolding_host.py` → `framework/cli/tests/test_scaffolding_host.py` +- Move: `framework/hosting/tests/test_scaffolding_module.py` → `framework/cli/tests/test_scaffolding_module.py` +- Update: import sites in moved tests + in `simple_module_hosting/cli/recipes.py` + in `simple_module_hosting/app_project.py`. +- Modify: root `pyproject.toml` `[tool.ruff] extend-exclude` to point at the new templates path. + +- [ ] **Step 1: Move templates wholesale** + +```bash +git mv framework/hosting/simple_module_hosting/templates framework/cli/simple_module/templates +``` + +- [ ] **Step 2: Update root `pyproject.toml` ruff exclude** + +Edit `pyproject.toml` (root). Replace: + +```toml +extend-exclude = ["framework/hosting/simple_module_hosting/templates"] +``` + +with: + +```toml +extend-exclude = ["framework/cli/simple_module/templates"] +``` + +- [ ] **Step 3: Create `framework/cli/simple_module/scaffolding.py`** + +This is a near-verbatim copy of the current `framework/hosting/simple_module_hosting/scaffolding.py`, **minus** `create_app_project` (still in hosting; moves in Task 4) and minus the case helpers (moved in Task 2). + +```python +"""Host + module scaffolding via package-data templates. + +* :func:`create_host` materializes a new host project from the templates + under ``simple_module/templates/host/``. +* :func:`create_module` materializes a new module package from + ``simple_module/templates/module/``. + +The frontend pages manifest + per-module JS dep discovery live in +:mod:`simple_module_hosting.manifest` (those need module-discovery and +stay in hosting). +""" + +from __future__ import annotations + +import importlib.resources +import logging +import re +import shutil +from collections.abc import Mapping, Sequence +from pathlib import Path + +from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case + +__all__ = ["create_host", "create_module"] + +logger = logging.getLogger(__name__) + +_TEMPLATES_PACKAGE = "simple_module.templates" +_PACKAGE_PATH_TOKEN = "__PACKAGE__" + + +def _module_to_pypi_name(name: str) -> str: + return f"simple_module_{name.lower()}" + + +def _iter_template_files(template_root: Path): + """Yield every file under ``template_root``. Skips ``_optional/`` paths.""" + for path in template_root.rglob("*"): + if not path.is_file(): + continue + if "_optional" in path.relative_to(template_root).parts: + continue + yield path + + +def _require_empty_dest(dest: Path) -> None: + if dest.exists() and any(dest.iterdir()): + raise FileExistsError( + f"Destination {dest} already exists and is non-empty. " + "Choose a new path or remove the contents first." + ) + dest.mkdir(parents=True, exist_ok=True) + + +def _resolve_template_root(subdir: str, override: Path | None) -> Path: + if override is not None: + return Path(override) + return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir)) + + +def _apply_template_files( + src_root: Path, + dest: Path, + substitutions: Mapping[str, str], + *, + path_rewrites: Mapping[str, str] | None = None, +) -> None: + for src in _iter_template_files(src_root): + rel_str = str(src.relative_to(src_root)) + for old, new in (path_rewrites or {}).items(): + rel_str = rel_str.replace(old, new) + rel_str = rel_str.removesuffix(".tpl") + target = dest / rel_str + target.parent.mkdir(parents=True, exist_ok=True) + if src.suffix == ".tpl": + text = src.read_text(encoding="utf-8") + for placeholder, value in substitutions.items(): + text = text.replace(placeholder, value) + target.write_text(text, encoding="utf-8") + else: + shutil.copy2(src, target) + + +def create_host( + dest: Path, + name: str, + modules: Sequence[str], + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules) + _apply_template_files( + _resolve_template_root("host", template_root), + dest, + {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines}, + ) + logger.info( + "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" + ) + return dest + + +def create_module( + dest: Path, + name: str, + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + display_name = to_pascal_case(name) + slug = to_kebab_case(name) + package_name = to_snake_case(name) + _apply_template_files( + _resolve_template_root("module", template_root), + dest, + substitutions={ + "{{MODULE_NAME}}": display_name, + "{{MODULE_SLUG}}": slug, + "{{PACKAGE_NAME}}": package_name, + }, + path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, + ) + logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name) + return dest +``` + +- [ ] **Step 4: Add `force-include` for templates in `framework/cli/pyproject.toml`** + +Append to the `[tool.hatch.build.targets.wheel]` block in `framework/cli/pyproject.toml`: + +```toml +[tool.hatch.build.targets.wheel] +packages = ["simple_module"] + +[tool.hatch.build.targets.wheel.shared-data] +"simple_module/templates" = "simple_module/templates" +``` + +(Hatchling auto-includes anything inside a `packages = [...]` directory by default. The shared-data block is belt-and-braces for files like `Makefile.snippet` whose extensions aren't recognized as package code.) + +- [ ] **Step 5: Reduce hosting's `scaffolding.py` to a thin shim** + +Replace `framework/hosting/simple_module_hosting/scaffolding.py` with: + +```python +"""Re-exports of moved scaffolding APIs. + +The actual implementations live in :mod:`simple_module.scaffolding`. This +shim lets the in-tree hosting code keep importing from the historical +path during the migration; it is removed in the final cleanup task. +""" + +from __future__ import annotations + +from simple_module.scaffolding import create_host, create_module + +from simple_module_hosting.manifest import ( + collect_module_js_deps, + compute_module_pages, + read_module_package_json, + repo_root_from_client_app, + write_module_pages_manifest, +) + +# create_app_project lives in app_project.py; re-exported via __all__. +from simple_module_hosting.app_project import create_app_project as create_app_project + +__all__ = [ + "collect_module_js_deps", + "compute_module_pages", + "create_app_project", + "create_host", + "create_module", + "read_module_package_json", + "repo_root_from_client_app", + "write_module_pages_manifest", +] +``` + +- [ ] **Step 6: Update hosting's `app_project.py` to import `create_host` from the new location** + +Inside `create_app_project`, change the local import: + +```python +from simple_module.scaffolding import create_host +``` + +(Keep the `simple_module_hosting.cli.{catalog,recipes}` imports — those move in Task 4.) + +- [ ] **Step 7: Move test files** + +```bash +git mv framework/hosting/tests/test_scaffolding_host.py framework/cli/tests/test_scaffolding_host.py +git mv framework/hosting/tests/test_scaffolding_module.py framework/cli/tests/test_scaffolding_module.py +``` + +- [ ] **Step 8: Update imports in the moved tests** + +In both moved files, replace `from simple_module_hosting.scaffolding import ...` with `from simple_module.scaffolding import ...`. The `from simple_module_hosting.cli import main` in the `Click sm create-host/create-module` integration tests stays — those tests get rewritten in Task 5. + +Concretely, in `framework/cli/tests/test_scaffolding_host.py`: +- Line 14: `from simple_module_hosting.scaffolding import compute_module_pages` — leave for now (`compute_module_pages` is re-exported via the shim — works through Task 8). +- All other `from simple_module_hosting.scaffolding import create_host` / `compute_module_pages` lines: leave them; they work through the shim. + +In `framework/cli/tests/test_scaffolding_module.py`: +- All `from simple_module_hosting.scaffolding import create_module` lines: leave them; the shim re-exports `create_module`. + +This is intentional — Task 3 just relocates; the shim keeps tests green. Tests get retargeted in Task 8 when the shim is removed. + +- [ ] **Step 9: Add `framework/cli/conftest.py` if needed** + +If the moved tests reference framework-level fixtures (like `db_session`), they pull in heavy deps. Inspect: + +Run: `grep -n 'db_session\|app\|authenticated_client\|settings' framework/cli/tests/test_scaffolding_host.py framework/cli/tests/test_scaffolding_module.py` +Expected: no matches (these are pure scaffolding tests, no fixture deps). + +If matches appear, decide test-by-test: either leave the test in `framework/hosting/tests/` (if it really needs runtime fixtures) or write a slim `framework/cli/conftest.py` covering only the needed bits. For the planned moves, no conftest is needed. + +- [ ] **Step 10: Run pytest** + +Run: `uv run pytest framework/cli/ framework/hosting/ -q` +Expected: all tests pass (the relocated `test_scaffolding_*` files run from their new home; everything else unaffected). + +- [ ] **Step 11: Commit** + +```bash +git add -A +git commit -m "refactor(cli): move scaffolding + templates to simple_module package + +$(cat <<'EOF' +create_host, create_module, _apply_template_files, and the entire +templates/ tree relocate from simple_module_hosting to the new +simple_module package. Hosting's scaffolding.py becomes a re-export +shim so existing import sites keep working through Task 7. +EOF +)" +``` + +--- + +## Task 4: Move `app_project.py`, `catalog.py`, `wizard.py`, `recipes.py` + +Click is still in play — this task only relocates code without changing the framework. Typer port lands in Task 5. + +**Files:** +- Move: `framework/hosting/simple_module_hosting/app_project.py` → `framework/cli/simple_module/app_project.py` +- Move: `framework/hosting/simple_module_hosting/cli/{catalog,wizard,recipes}.py` → `framework/cli/simple_module/{catalog,wizard,recipes}.py` +- Move: tests `test_cli_{catalog,wizard,recipes}.py` → `framework/cli/tests/` +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py` (shim — `create_app_project` re-import path). +- Modify: `framework/hosting/simple_module_hosting/cli/__init__.py` — drop the catalog/wizard/recipes imports from `new_project`. + +- [ ] **Step 1: Move the four source files** + +```bash +git mv framework/hosting/simple_module_hosting/app_project.py framework/cli/simple_module/app_project.py +git mv framework/hosting/simple_module_hosting/cli/catalog.py framework/cli/simple_module/catalog.py +git mv framework/hosting/simple_module_hosting/cli/wizard.py framework/cli/simple_module/wizard.py +git mv framework/hosting/simple_module_hosting/cli/recipes.py framework/cli/simple_module/recipes.py +``` + +- [ ] **Step 2: Fix imports inside the moved files** + +In `framework/cli/simple_module/app_project.py`, change: +- `from simple_module._env import set_env_key` — already correct. +- `from simple_module.case import to_kebab_case, to_pascal_case` — already correct. +- Inside `create_app_project`, replace the local imports: + +```python +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> None: + from simple_module.catalog import CATALOG, PRESETS, expand_deps + from simple_module.recipes import RECIPES, ScaffoldCtx + from simple_module.scaffolding import create_host + ... +``` + +In `framework/cli/simple_module/wizard.py`: +- `from .catalog import CATALOG, PRESETS, expand_deps` → `from simple_module.catalog import CATALOG, PRESETS, expand_deps` +- (Or use relative imports; they all live in the same package now: `from .catalog import ...` still works.) + +In `framework/cli/simple_module/recipes.py`: +- `from simple_module._env import set_env_key` — already correct. +- The `_optional_template_root` helper currently does `importlib.resources.files("simple_module_hosting")` — change to `importlib.resources.files("simple_module")`: + +```python +def _optional_template_root(name: str) -> Path: + """Resolve ``templates/host/_optional//`` from package data.""" + base = importlib.resources.files("simple_module") + return Path(str(base / "templates" / "host" / "_optional" / name)) +``` + +In `framework/cli/simple_module/catalog.py`: +- No external imports — leave as is. + +- [ ] **Step 3: Move the three test files** + +```bash +git mv framework/hosting/tests/test_cli_catalog.py framework/cli/tests/test_cli_catalog.py +git mv framework/hosting/tests/test_cli_wizard.py framework/cli/tests/test_cli_wizard.py +git mv framework/hosting/tests/test_cli_recipes.py framework/cli/tests/test_cli_recipes.py +``` + +- [ ] **Step 4: Update imports in the three moved test files** + +`framework/cli/tests/test_cli_catalog.py`: +```python +from simple_module.catalog import ( + CATALOG, + PRESETS, + ModuleEntry, + expand_deps, +) +``` + +`framework/cli/tests/test_cli_wizard.py`: +```python +from simple_module.wizard import run_wizard +``` + +`framework/cli/tests/test_cli_recipes.py`: +```python +from simple_module.recipes import ( + RECIPES, + BackgroundTasksRecipe, + ScaffoldCtx, +) +from simple_module.scaffolding import create_host +``` + +- [ ] **Step 5: Patch the hosting scaffolding shim** + +The shim from Task 3 already re-exports `create_app_project` from `simple_module_hosting.app_project` — but that file just moved. Update the shim to re-export from the new home: + +In `framework/hosting/simple_module_hosting/scaffolding.py`, change: + +```python +from simple_module_hosting.app_project import create_app_project as create_app_project +``` + +to: + +```python +from simple_module.app_project import create_app_project as create_app_project +``` + +- [ ] **Step 6: Patch the hosting `cli/__init__.py` imports** + +The Click `new_project` command in `framework/hosting/simple_module_hosting/cli/__init__.py` is removed in Task 5; for now, just adjust its imports so tests keep passing: + +Edit `framework/hosting/simple_module_hosting/cli/__init__.py`: +- The bottom-of-file `from .new import new_project as _new_project` line: change to `from simple_module.new import new_project as _new_project` for now. (Both files still exist via Click; Task 5 deletes the `new.py` in hosting.) + +Wait — `cli/new.py` was already moved out by `git mv` in Task 4 Step 1? No — I only moved `catalog.py`, `wizard.py`, `recipes.py`. The `new.py` is still in `simple_module_hosting/cli/new.py`. Move it now too: + +```bash +git mv framework/hosting/simple_module_hosting/cli/new.py framework/cli/simple_module/new.py +``` + +In the moved `framework/cli/simple_module/new.py`, update imports: + +```python +from simple_module.app_project import create_app_project +from simple_module.catalog import PRESETS, expand_deps +from simple_module.wizard import run_wizard +``` + +And in `framework/hosting/simple_module_hosting/cli/__init__.py`: + +```python +from simple_module.new import new_project as _new_project # noqa: E402 +``` + +- [ ] **Step 7: Move `test_cli_new.py` and update imports** + +```bash +git mv framework/hosting/tests/test_cli_new.py framework/cli/tests/test_cli_new.py +``` + +In `framework/cli/tests/test_cli_new.py`, change: +- `from simple_module_hosting.cli import main` — leave for now; `simple_module_hosting.cli:main` still works (Click group + the relocated `new_project` registered into it). The Typer port in Task 5 will switch this to `from simple_module.cli import app` and `runner.invoke(app, ...)`. +- All `from simple_module_hosting.scaffolding import create_app_project` — leave; works through the Task 3 shim. + +- [ ] **Step 8: Run the test suite** + +Run: `uv run pytest framework/cli/ framework/hosting/ -q` +Expected: all tests pass — the existing Click `sm` is still functional through the hosting package; tests in `framework/cli/tests/` import from the moved homes and exercise the same code. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "refactor(cli): move app_project, catalog, wizard, recipes, new to simple_module + +$(cat <<'EOF' +All scaffolding logic now lives in the simple_module package. Hosting's +cli/__init__.py click group still works (it imports new_project from +the new location); fully replaced by the Typer port in the next commit. +EOF +)" +``` + +--- + +## Task 5: Convert CLI to Typer + build root `cli.py` + +Big change. Rewrites the four command files — `new.py`, `cli.py` (new), and the wizard's prompt usage — from Click to Typer. Updates the test runner. + +**Files:** +- Modify: `framework/cli/simple_module/new.py` (Click decorators → Typer). +- Modify: `framework/cli/simple_module/wizard.py` (Click prompts → Typer prompts). +- Create: `framework/cli/simple_module/cli.py` (root Typer app + `create-host` / `create-module` commands + `main`). +- Modify: all five `framework/cli/tests/test_cli_*.py` files: `from click.testing import CliRunner` → `from typer.testing import CliRunner`; `from simple_module_hosting.cli import main` → `from simple_module.cli import app`. +- Delete: `framework/hosting/simple_module_hosting/cli/__init__.py` (and the empty `cli/` dir). + +- [ ] **Step 1: Rewrite `framework/cli/simple_module/new.py` in Typer style** + +Replace the entire file with: + +```python +"""``sm new`` Typer command — flag-driven or interactive scaffolder.""" + +from __future__ import annotations + +import subprocess +import sys +from enum import Enum +from pathlib import Path +from typing import Annotated + +import typer + +from simple_module.app_project import create_app_project +from simple_module.catalog import PRESETS, expand_deps +from simple_module.wizard import run_wizard + +__all__ = ["new_project"] + + +class Db(str, Enum): + sqlite = "sqlite" + postgres = "postgres" + + +class Preset(str, Enum): + minimal = "minimal" + standard = "standard" + full = "full" + + +def new_project( + name: Annotated[str, typer.Argument(help="App name (used for directory + package).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + db: Annotated[ + Db, + typer.Option("--db", help="Database backend to configure in .env.example."), + ] = Db.sqlite, + tenancy: Annotated[ + bool, + typer.Option("--tenancy/--no-tenancy", help="Enable the multi-tenant middleware."), + ] = False, + preset: Annotated[ + Preset | None, + typer.Option("--preset", help="Module preset. Combine with --with."), + ] = None, + extra: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated extra modules (e.g. background_tasks,file_storage).", + ), + ] = "", + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip interactive prompts; accept defaults."), + ] = False, + no_install: Annotated[ + bool, + typer.Option( + "--no-install", + help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", + ), + ] = False, +) -> None: + """Scaffold a new SimpleModule app, optionally with background jobs.""" + target = dest or Path.cwd() / name + extra_list = [m.strip() for m in extra.split(",") if m.strip()] + flag_driven = preset is not None or bool(extra_list) + db_value: str = db.value + tenancy_value: bool = tenancy + + if yes or flag_driven: + chosen = list(PRESETS[(preset or Preset.standard).value]) + extra_list + try: + resolved, added = expand_deps(chosen) + except KeyError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + for added_name, required_by in added: + typer.echo(f"Added {added_name} (required by {required_by})") + else: + try: + db_value, tenancy_value, resolved = run_wizard( + default_db=db.value, default_tenancy=tenancy + ) + except typer.Abort: + typer.echo("Aborted.", err=True) + raise typer.Exit(code=1) + + try: + create_app_project( + target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved + ) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created app '{name}' at {target}") + typer.echo(f"Modules: {', '.join(resolved)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + if no_install: + typer.echo(" uv sync") + typer.echo(" npm install") + typer.echo(" alembic upgrade head") + typer.echo(" make dev") + if "background_tasks" in resolved: + typer.echo(" docker compose up -d redis worker beat # background jobs") + return + + typer.echo("Installing dependencies...") + for cmd in (["uv", "sync"], ["npm", "install"]): + result = subprocess.run(cmd, cwd=target, check=False) + if result.returncode != 0: + typer.echo( + f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " + "finish setup manually.", + err=True, + ) + return + + subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) + typer.echo("\nSetup complete. Run `make dev` in the new directory.") + if "background_tasks" in resolved: + typer.echo("For background jobs, also run: docker compose up -d redis worker beat") +``` + +- [ ] **Step 2: Rewrite `framework/cli/simple_module/wizard.py` to use Typer prompts** + +Replace the file with: + +```python +"""Interactive prompt sequence for ``sm new``.""" + +from __future__ import annotations + +import typer + +from simple_module.catalog import CATALOG, PRESETS, expand_deps + +__all__ = ["run_wizard"] + +_PRESET_CHOICES = ("minimal", "standard", "full", "custom") + + +def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: + db = typer.prompt( + "Database backend", default=default_db, type=str + ) + if db not in ("sqlite", "postgres"): + typer.echo(f"Invalid database: {db!r}; expected sqlite or postgres", err=True) + raise typer.Abort() + tenancy = typer.confirm("Enable multi-tenancy?", default=default_tenancy) + + typer.echo("\nPreset:") + typer.echo(" [1] minimal — users only") + typer.echo(" [2] standard — users, dashboard, permissions (default)") + typer.echo(" [3] full — every module") + typer.echo(" [4] custom — pick modules one by one") + choice = typer.prompt("Choose", default="2", type=str) + if choice not in {"1", "2", "3", "4"}: + typer.echo(f"Invalid choice: {choice!r}", err=True) + raise typer.Abort() + preset_name = _PRESET_CHOICES[int(choice) - 1] + + if preset_name == "custom": + picked = [ + name + for name in CATALOG + if typer.confirm(f"Include {CATALOG[name].display}?", default=False) + ] + else: + picked = list(PRESETS[preset_name]) + + resolved, added = expand_deps(picked) + for name, required_by in added: + typer.echo(f"Added {name} (required by {required_by})") + typer.echo(f"Selected modules: {', '.join(resolved)}") + + if not typer.confirm("Proceed?", default=True): + raise typer.Abort() + return db, tenancy, resolved +``` + +(Note: typer.prompt doesn't have a built-in `choice` validator the way `click.prompt` does, so we validate manually after the prompt and raise `typer.Abort` on invalid input. Behavior equivalent for the test scenarios.) + +- [ ] **Step 3: Create `framework/cli/simple_module/cli.py`** (root app) + +Replace the stub from Task 1 with: + +```python +"""Root `sm` Typer app — scaffolders + plugin mount. + +Built-in commands: + sm new + sm create-host + sm create-module + +Plugins discovered via the ``simple_module.cli_plugins`` entry-point +group are mounted as named subgroups (e.g. ``sm host gen-pages``). +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Annotated + +import typer + +from simple_module.case import to_kebab_case +from simple_module.new import new_project +from simple_module.plugins import discover_and_mount +from simple_module.scaffolding import create_host as _create_host +from simple_module.scaffolding import create_module as _create_module + +app = typer.Typer( + help="SimpleModule developer CLI.", + no_args_is_help=True, + add_completion=False, +) + +# Built-in commands +app.command("new")(new_project) + + +@app.command("create-host") +def create_host( + name: Annotated[str, typer.Argument(help="Host project name.")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + modules: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated module names to declare as deps (e.g. Auth,Products).", + ), + ] = "", +) -> None: + """Scaffold a new SimpleModule host project at ./.""" + target = dest or Path.cwd() / name + selected = [m.strip() for m in modules.split(",") if m.strip()] + try: + _create_host(target, name=name, modules=selected) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created host '{name}' at {target}") + if selected: + typer.echo(f"Declared modules: {', '.join(selected)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + typer.echo(" uv sync") + typer.echo(" cp .env.example .env") + typer.echo(' alembic revision --autogenerate -m "initial schema"') + typer.echo(" alembic upgrade head") + typer.echo(" python main.py") + + +@app.command("create-module") +def create_module( + name: Annotated[str, typer.Argument(help="Module name (any case).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination dir. Defaults to ./simple_module_."), + ] = None, +) -> None: + """Scaffold a publishable SimpleModule module package.""" + slug = to_kebab_case(name) + package = slug.replace("-", "_") + target = dest or Path.cwd() / f"simple_module_{package}" + try: + _create_module(target, name=name) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created module 'simple_module_{package}' at {target}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + typer.echo(" uv sync --extra dev") + typer.echo(" uv run pytest") + + +# Plugin discovery — mount any installed `simple_module.cli_plugins` +# entry-point apps as named subgroups (e.g. `sm host gen-pages`). +discover_and_mount(app) + + +def main() -> None: + """Entry point for the `sm` console script.""" + app() + + +if __name__ == "__main__": + main() +``` + +(Note: `discover_and_mount` is implemented in Task 6. For now this import will fail — that's OK; we land both tasks before re-running the suite.) + +- [ ] **Step 4: Stub `framework/cli/simple_module/plugins.py`** + +Create a minimal stub so Task 5 can run end-to-end before Task 6 fleshes it out: + +```python +"""Plugin discovery via ``simple_module.cli_plugins`` entry points. + +Real implementation lands in Task 6. For now this is a no-op so the +root Typer app imports cleanly. +""" + +from __future__ import annotations + +import typer + + +def discover_and_mount(app: typer.Typer) -> None: + """No-op stub. Implemented in Task 6.""" +``` + +- [ ] **Step 5: Update test files for Typer** + +Replace `from click.testing import CliRunner` with `from typer.testing import CliRunner` in: +- `framework/cli/tests/test_cli_new.py` +- (`test_cli_wizard.py` uses Click's CliRunner inside a wrapper — see below) + +In `framework/cli/tests/test_cli_new.py`: +- `from simple_module_hosting.cli import main` → `from simple_module.cli import app` +- All `runner.invoke(main, ["new", ...])` → `runner.invoke(app, ["new", ...])` +- All `from simple_module_hosting.scaffolding import create_app_project` → `from simple_module.app_project import create_app_project` + +In `framework/cli/tests/test_cli_wizard.py`: + +The wizard tests currently wrap `run_wizard` inside an ad-hoc `@click.command` for stdin driving. Rewrite the helper to use a Typer app: + +```python +from __future__ import annotations + +import typer +from typer.testing import CliRunner + +from simple_module.wizard import run_wizard + + +def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: + captured: dict = {} + wrapper_app = typer.Typer() + + @wrapper_app.command() + def wrapper() -> None: + db, tenancy, selected = run_wizard(default_db="sqlite", default_tenancy=False) + captured["db"] = db + captured["tenancy"] = tenancy + captured["selected"] = selected + + runner = CliRunner() + result = runner.invoke(wrapper_app, [], input="\n".join(answers) + "\n") + assert result.exit_code == 0, result.output + return captured["db"], captured["tenancy"], captured["selected"], result.output + + +def test_wizard_aborts_on_confirm_no() -> None: + captured: dict = {} + wrapper_app = typer.Typer() + + @wrapper_app.command() + def wrapper() -> None: + try: + run_wizard(default_db="sqlite", default_tenancy=False) + except typer.Abort: + captured["aborted"] = True + raise + + runner = CliRunner() + result = runner.invoke(wrapper_app, [], input="\n".join(["", "", "", "n"]) + "\n") + assert result.exit_code != 0 + assert captured.get("aborted") is True +``` + +(All other wizard test bodies stay the same — they call `_drive(...)` and assert.) + +- [ ] **Step 6: Delete the hosting CLI package** + +The Click `sm` entry point in `simple_module_hosting` is being replaced by `simple_module.cli:main`. Delete: + +```bash +git rm -r framework/hosting/simple_module_hosting/cli/ +``` + +(This removes `__init__.py`, `__pycache__/`, and any leftover files.) + +In `framework/hosting/pyproject.toml`, drop the script entries (Task 7 adds the entry-point line): + +```toml +# REMOVE THESE TWO LINES: +sm = "simple_module_hosting.cli:main" +simple-module = "simple_module_hosting.cli:main" +``` + +Leave the `[project.scripts]` table empty for now (or remove it entirely if no scripts remain). + +- [ ] **Step 7: Update remaining hosting test files for the move** + +The two tests that import `from simple_module_hosting.cli import main` (`framework/cli/tests/test_scaffolding_host.py`, `framework/cli/tests/test_scaffolding_module.py`) need updating: + +```python +# Before +from simple_module_hosting.cli import main +runner.invoke(main, ["create-host", ...]) + +# After +from simple_module.cli import app +runner.invoke(app, ["create-host", ...]) +``` + +Also switch `from click.testing import CliRunner` → `from typer.testing import CliRunner` in those two files. + +- [ ] **Step 8: Update `framework/cli/tests/test_cli_recipes.py`** + +`from simple_module_hosting.scaffolding import create_host` → `from simple_module.scaffolding import create_host`. The shim still works, but the direct path is preferred now. + +- [ ] **Step 9: Re-install workspace and run tests** + +Run: `uv sync --all-packages && uv run pytest framework/cli/ framework/hosting/ -q` +Expected: all tests pass. The `sm` console script is now provided by `simple-module`; the old `simple_module_hosting.cli:main` no longer exists. + +Smoke check the binary: + +Run: `uv run sm --help` +Expected: lists `new`, `create-host`, `create-module` (no plugins yet — Task 6). + +Run: `uv run sm new demo --yes --preset full --no-install --dest /tmp/sm-typer-smoke` +Expected: works the same as before; produces the demo project. Clean up: `rm -rf /tmp/sm-typer-smoke`. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "feat(cli): port sm to Typer; new simple-module package owns the binary + +$(cat <<'EOF' +- All Click decorators rewritten in Typer's Annotated[] style. +- Wizard uses typer.prompt / typer.confirm. +- Tests use typer.testing.CliRunner (drop-in for click.testing). +- simple_module_hosting drops sm/simple-module console scripts. +- simple_module_hosting/cli/ package deleted. +- Plugin discovery hook added (no-op stub; real impl next commit). +EOF +)" +``` + +--- + +## Task 6: Plugin discovery + +**Files:** +- Modify: `framework/cli/simple_module/plugins.py` (real implementation). +- Create: `framework/cli/tests/test_plugin_discovery.py` + +- [ ] **Step 1: Write the failing test** + +Create `framework/cli/tests/test_plugin_discovery.py`: + +```python +"""Tests for entry-point-based plugin discovery.""" + +from __future__ import annotations + +from importlib.metadata import EntryPoint + +import pytest +import typer +from typer.testing import CliRunner + +from simple_module.plugins import discover_and_mount + + +def _make_entry(name: str, module_attr: str) -> EntryPoint: + return EntryPoint(name=name, value=module_attr, group="simple_module.cli_plugins") + + +@pytest.fixture +def fake_plugin_module(tmp_path, monkeypatch): + """Create a tiny package on sys.path that exports a Typer ``app``.""" + import sys + import textwrap + + pkg_dir = tmp_path / "fake_sm_plugin" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text( + textwrap.dedent( + """ + import typer + app = typer.Typer(help="Fake plugin.") + + @app.command("ping") + def ping(): + typer.echo("pong-from-fake") + """ + ) + ) + monkeypatch.syspath_prepend(str(tmp_path)) + yield "fake_sm_plugin:app" + sys.modules.pop("fake_sm_plugin", None) + + +def test_discover_mounts_valid_plugin(monkeypatch, fake_plugin_module) -> None: + monkeypatch.setattr( + "simple_module.plugins._iter_plugin_entries", + lambda: [_make_entry("fake", fake_plugin_module)], + ) + root = typer.Typer() + discover_and_mount(root) + + runner = CliRunner() + result = runner.invoke(root, ["fake", "ping"]) + assert result.exit_code == 0, result.output + assert "pong-from-fake" in result.output + + +def test_discover_skips_broken_plugin(monkeypatch) -> None: + bad = _make_entry("broken", "nonexistent_module:app") + monkeypatch.setattr( + "simple_module.plugins._iter_plugin_entries", lambda: [bad] + ) + root = typer.Typer() + discover_and_mount(root) # should not raise + + runner = CliRunner() + result = runner.invoke(root, ["broken"]) + assert result.exit_code != 0 # subgroup absent → error + + +def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, capsys) -> None: + a = _make_entry("dup", fake_plugin_module) + b = _make_entry("dup", fake_plugin_module) + monkeypatch.setattr( + "simple_module.plugins._iter_plugin_entries", lambda: [a, b] + ) + root = typer.Typer() + discover_and_mount(root) + captured = capsys.readouterr() + assert "duplicate" in captured.err.lower() or "already" in captured.err.lower() + + +def test_discover_with_no_plugins_is_noop(monkeypatch) -> None: + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: []) + root = typer.Typer() + discover_and_mount(root) # no error, no commands added + runner = CliRunner() + result = runner.invoke(root, ["--help"]) + assert result.exit_code == 0 +``` + +- [ ] **Step 2: Run the failing tests** + +Run: `uv run pytest framework/cli/tests/test_plugin_discovery.py -v` +Expected: FAIL — `_iter_plugin_entries` does not exist yet, plus the stub doesn't actually mount. + +- [ ] **Step 3: Implement `framework/cli/simple_module/plugins.py`** + +Replace the stub with: + +```python +"""Plugin discovery for ``sm`` via the ``simple_module.cli_plugins`` group. + +Each entry-point's value (``module:attr``) must resolve to a +:class:`typer.Typer` instance. The entry-point name becomes the +subcommand namespace under ``sm`` (e.g. ``sm host gen-pages``). + +Failed loads (broken import, wrong type) print one line to stderr and +are skipped — ``sm`` keeps working with whatever else loads. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterator +from importlib.metadata import EntryPoint, entry_points + +import typer + +__all__ = ["discover_and_mount"] + +_GROUP = "simple_module.cli_plugins" + + +def _iter_plugin_entries() -> Iterator[EntryPoint]: + """Indirection point for tests to inject fake entry points.""" + yield from entry_points(group=_GROUP) + + +def discover_and_mount(root: typer.Typer) -> None: + """Mount every installed plugin under its entry-point name.""" + seen: set[str] = set() + for entry in _iter_plugin_entries(): + if entry.name in seen: + print( + f"[simple-module] duplicate plugin subgroup '{entry.name}' " + f"from {entry.value!r}; keeping first registration.", + file=sys.stderr, + ) + continue + try: + plugin_app = entry.load() + except Exception as exc: # noqa: BLE001 — plugin authors can fail in any way + print( + f"[simple-module] failed to load plugin '{entry.name}' " + f"({entry.value}): {exc}", + file=sys.stderr, + ) + continue + if not isinstance(plugin_app, typer.Typer): + print( + f"[simple-module] plugin '{entry.name}' did not export a " + f"typer.Typer instance (got {type(plugin_app).__name__}); skipping.", + file=sys.stderr, + ) + continue + root.add_typer(plugin_app, name=entry.name) + seen.add(entry.name) +``` + +- [ ] **Step 4: Run the tests** + +Run: `uv run pytest framework/cli/tests/test_plugin_discovery.py -v` +Expected: 4 tests pass. + +- [ ] **Step 5: Verify `sm --help` still works (no plugins installed yet)** + +Run: `uv run sm --help` +Expected: same output as before, no errors. No `host`, `users`, or `settings` subgroups appear yet (those are wired in Tasks 7 & 8). + +- [ ] **Step 6: Commit** + +```bash +git add framework/cli/simple_module/plugins.py framework/cli/tests/test_plugin_discovery.py +git commit -m "feat(cli): plugin discovery via simple_module.cli_plugins entry points + +$(cat <<'EOF' +discover_and_mount() walks the entry-point group, validates each load +target is a typer.Typer, and mounts it under its entry name. Broken or +duplicate plugins log one line to stderr and are skipped. +EOF +)" +``` + +--- + +## Task 7: Carve out `simple_module_hosting.host_cli` plugin + +**Files:** +- Create: `framework/hosting/simple_module_hosting/host_cli.py` (Typer app with `gen-pages` + `sync-js-deps`). +- Modify: `framework/hosting/pyproject.toml` (add `[project.entry-points."simple_module.cli_plugins"]`). +- Modify: `Makefile` (`sm gen-pages` → `sm host gen-pages`; `sm sync-js-deps` → `sm host sync-js-deps`). +- Create: `framework/hosting/tests/test_host_cli.py` (smoke). + +- [ ] **Step 1: Write the failing test** + +Create `framework/hosting/tests/test_host_cli.py`: + +```python +"""Smoke tests for the simple_module_hosting host_cli Typer plugin.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from typer.testing import CliRunner + +from simple_module_hosting.host_cli import app + + +def test_app_is_typer_instance() -> None: + assert isinstance(app, typer.Typer) + + +def test_help_lists_gen_pages_and_sync_js_deps() -> None: + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "gen-pages" in result.output + assert "sync-js-deps" in result.output + + +def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke( + app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")] + ) + assert result.exit_code != 0 + assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower() +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `uv run pytest framework/hosting/tests/test_host_cli.py -v` +Expected: FAIL — `simple_module_hosting.host_cli` does not exist. + +- [ ] **Step 3: Create `framework/hosting/simple_module_hosting/host_cli.py`** + +Translate the existing `gen-pages` and `sync-js-deps` Click commands to Typer: + +```python +"""``sm host`` plugin — project-time helpers exposed through the simple-module CLI. + +Commands here need module discovery (``simple_module_core.discover_modules``) +and the manifest helpers; they're not part of the standalone scaffolder. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Annotated + +import typer +from simple_module_core import discover_modules + +from simple_module_hosting.manifest import ( + collect_module_js_deps, + repo_root_from_client_app, + write_module_pages_manifest, +) + +app = typer.Typer( + help="Project-time helpers (frontend pages manifest, module JS dep sync).", + no_args_is_help=True, +) + + +@app.command("gen-pages") +def gen_pages( + host_dir: Annotated[ + Path, + typer.Option( + "--host-dir", + help="Path to the host's client_app directory. Defaults to ./client_app.", + ), + ] = Path("client_app"), +) -> None: + """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_dir.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_dir}", err=True) + raise typer.Exit(code=1) + modules = discover_modules() + written = write_module_pages_manifest(modules, host_dir) + typer.echo( + f"Wrote {written['manifest'].name}, {written['generated'].name}, " + f"{written['css'].name} to {host_dir}" + ) + + +@app.command("sync-js-deps") +def sync_js_deps( + host_client_app: Annotated[ + Path, + typer.Option( + "--host-client-app", + help="Path to host/client_app. Defaults to ./client_app.", + ), + ] = Path("client_app"), + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Print the npm install command only.") + ] = False, +) -> None: + """Install JS deps declared by installed modules into host's node_modules.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_client_app.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_client_app}", err=True) + raise typer.Exit(code=1) + + modules = discover_modules() + by_module = collect_module_js_deps(modules) + if not by_module: + typer.echo("No module JS dependencies declared.") + return + + specs: list[str] = [] + for mod_name in sorted(by_module): + for dep, rng in sorted(by_module[mod_name].items()): + specs.append(f"{dep}@{rng}") + deduped: list[str] = [] + seen: set[str] = set() + for spec in specs: + if spec not in seen: + seen.add(spec) + deduped.append(spec) + + npm = shutil.which("npm") + if npm is None: + typer.echo("ERROR: npm not found on PATH.", err=True) + raise typer.Exit(code=1) + + repo_root = repo_root_from_client_app(host_client_app) + try: + workspace = str(host_client_app.resolve().relative_to(repo_root)) + except ValueError: + workspace = str(host_client_app.resolve()) + + cmd = [ + npm, "install", "--workspace", workspace, + "--save=false", "--no-audit", "--no-fund", *deduped, + ] + typer.echo("Installing module JS deps:") + for spec in deduped: + typer.echo(f" {spec}") + if dry_run: + typer.echo("(dry-run) " + " ".join(cmd)) + return + result = subprocess.run(cmd, cwd=repo_root, check=False) + raise typer.Exit(code=result.returncode) +``` + +- [ ] **Step 4: Register the plugin entry point in `framework/hosting/pyproject.toml`** + +After `[project.urls]`, add: + +```toml +[project.entry-points."simple_module.cli_plugins"] +host = "simple_module_hosting.host_cli:app" +``` + +The `[project.scripts]` table should already be empty (or removed) from Task 5. + +- [ ] **Step 5: Run hosting tests** + +Run: `uv run pytest framework/hosting/tests/test_host_cli.py -v` +Expected: 3 tests pass. + +- [ ] **Step 6: Re-install workspace and verify the plugin mounts** + +Run: `uv sync --all-packages && uv run sm --help` +Expected: `host` appears in the subcommand list (from the entry point). + +Run: `uv run sm host --help` +Expected: lists `gen-pages` and `sync-js-deps`. + +- [ ] **Step 7: Update Makefile** + +In `Makefile`, replace: + +```makefile +gen-pages: + uv run --project host sm gen-pages --host-dir=host/client_app + +sync-module-deps: + uv run --project host sm sync-js-deps --host-client-app=host/client_app +``` + +with: + +```makefile +gen-pages: + uv run --project host sm host gen-pages --host-dir=host/client_app + +sync-module-deps: + uv run --project host sm host sync-js-deps --host-client-app=host/client_app +``` + +- [ ] **Step 8: Verify Make targets still work** + +Run: `make gen-pages 2>&1 | tail -3` +Expected: Wrote manifest.json, generated.ts, generated.css. (Or, if the host hasn't been bootstrapped, an unrelated error — what matters is the CLI invocation itself works. Confirm by inspecting stdout for the new "Wrote …" line.) + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(hosting): host_cli plugin (sm host gen-pages, sm host sync-js-deps) + +$(cat <<'EOF' +gen-pages and sync-js-deps move out of the deleted sm console script +and into a Typer plugin published under the simple_module.cli_plugins +entry-point group. Makefile updated for the new sm host * shape. +EOF +)" +``` + +--- + +## Task 8: Convert `users` and `settings` modules to plugins + +**Files:** +- Modify: `modules/users/pyproject.toml` (drop `sm-users`, add entry point). +- Modify: `modules/users/users/cli.py` (already a Typer app — verify; minimal changes). +- Modify: `modules/settings/settings/cli.py` (rewrite as Typer app named `app`). +- Modify: `modules/settings/pyproject.toml` (drop `sm-settings`, add entry point). +- Modify: `README.md` (`sm-users` / `sm-settings` snippets → `sm users` / `sm settings`). + +- [ ] **Step 1: Update `modules/users/pyproject.toml`** + +Find: + +```toml +[project.scripts] +sm-users = "users.cli:app" +``` + +Replace with: + +```toml +[project.entry-points."simple_module.cli_plugins"] +users = "users.cli:app" +``` + +`modules/users/users/cli.py` is already a Typer app named `app` — no code change needed. + +- [ ] **Step 2: Rewrite `modules/settings/settings/cli.py` as a Typer plugin** + +Replace the entire file with: + +```python +"""``sm settings`` plugin — currently only ``import-from-env``. + +One-shot migration: walks every registered module's BaseSettings and +writes a SYSTEM-scoped override for each ``SM__`` env +var that is set. +""" + +from __future__ import annotations + +import asyncio +import os + +import typer +from fastapi import FastAPI + +from settings.constants import MODULE_PACKAGE +from settings.env_vars import env_prefix_for +from settings.hydrate import value_type_for_field +from settings.store import SettingsStore + +app = typer.Typer(help="Settings module administration.", no_args_is_help=True) + + +async def import_from_env_impl(app_inst: FastAPI, store: SettingsStore) -> int: + """Write a SYSTEM override for every ``SM__`` env var set.""" + registry = getattr(app_inst.state, MODULE_PACKAGE).module_registry + count = 0 + for package, cls in registry.items(): + prefix = env_prefix_for(package) + for field_name in cls.model_fields: + raw = os.environ.get(f"{prefix}{field_name.upper()}") + if raw is None: + continue + vtype = value_type_for_field(cls, field_name) + await store.set_override(package, field_name, raw, vtype) + count += 1 + return count + + +@app.command("import-from-env") +def import_from_env() -> None: + """Write SYSTEM overrides for every SM__ env var set.""" + from simple_module_hosting.app_builder import create_app + from simple_module_hosting.settings import Settings + + from settings.service import SettingService + + fastapi_app = create_app(Settings()) + + async def run() -> int: + async with ( + fastapi_app.router.lifespan_context(fastapi_app), + fastapi_app.state.sm.db.session_factory() as session, + ): + store = SettingsStore(SettingService(session)) + n = await import_from_env_impl(fastapi_app, store) + await session.commit() + typer.echo(f"Imported {n} override(s) from environment.") + return 0 + + raise typer.Exit(code=asyncio.run(run())) +``` + +- [ ] **Step 3: Update `modules/settings/pyproject.toml`** + +Find: + +```toml +[project.scripts] +sm-settings = "settings.cli:main" +``` + +Replace with: + +```toml +[project.entry-points."simple_module.cli_plugins"] +settings = "settings.cli:app" +``` + +- [ ] **Step 4: Update existing settings test if any** + +Run: `grep -n "sm-settings\|settings.cli.main\|from settings.cli import main" modules/settings/tests/` +If there are matches, fix them — `main` no longer exists. The new entry is `app`. Most likely there are no test changes needed (the existing settings tests target `import_from_env_impl` directly). + +- [ ] **Step 5: Re-install + smoke** + +Run: `uv sync --all-packages && uv run sm --help 2>&1 | head -20` +Expected: subgroups `host`, `users`, `settings` all appear. + +Run: `uv run sm users --help` and `uv run sm settings --help` +Expected: each lists their subcommand(s). + +- [ ] **Step 6: Update README** + +In `README.md`, replace: +- `uv run sm-users create-admin --email …` → `uv run sm users create-admin --email …` +- `uv run sm-settings import-from-env` → `uv run sm settings import-from-env` +- Any other occurrences of `sm-users` / `sm-settings`. + +- [ ] **Step 7: Run the full test suite** + +Run: `uv run pytest -q` +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(modules): convert users + settings to sm plugins + +$(cat <<'EOF' +Drops sm-users and sm-settings console scripts. Both modules now +register Typer apps under the simple_module.cli_plugins entry-point +group, mounted as `sm users` and `sm settings`. settings/cli.py +rewritten as a Typer app (was hand-rolled Click-style argv parsing). +README updated for the new command shape. +EOF +)" +``` + +--- + +## Task 9: Final cleanup, dep guard, lint, full verification + +**Files:** +- Delete: `framework/hosting/simple_module_hosting/scaffolding.py` (the shim). +- Modify: `framework/hosting/pyproject.toml` (drop `simple_module` workspace dep — hosting no longer needs it). +- Modify: any remaining test imports referencing `simple_module_hosting.scaffolding`. +- Create: `framework/cli/tests/test_no_framework_deps.py`. +- Final: lint + typecheck + file-size + full pytest. + +- [ ] **Step 1: Find all remaining import sites of the shim** + +Run: `grep -rn 'from simple_module_hosting.scaffolding\|from simple_module_hosting.app_project\|from simple_module_hosting._env\|simple_module_hosting.cli' --include="*.py" framework/ modules/ host/ scripts/ tests/ 2>/dev/null` +Expected: a small list of test files and possibly internal hosting modules. + +For each match: +- If it's in a test file under `framework/cli/tests/`, change to import from the new `simple_module` location directly. +- If it's in `framework/hosting/simple_module_hosting/manifest.py` or another hosting runtime file, update to `simple_module.scaffolding` / `simple_module.app_project` etc. +- If it's outside the framework (host code, modules), update similarly. + +Concretely the planned remaining sites are in `framework/cli/tests/test_scaffolding_*.py` (already updated in Task 5 Step 7), `framework/cli/tests/test_cli_*.py` (updated Task 5 Step 5/8). Sweep once more to be sure. + +- [ ] **Step 2: Delete the shim** + +```bash +git rm framework/hosting/simple_module_hosting/scaffolding.py +``` + +- [ ] **Step 3: Drop the `simple_module` workspace dep from hosting** + +In `framework/hosting/pyproject.toml`, remove: + +```toml +"simple_module==0.0.1", +``` + +from `dependencies` and remove `simple_module = { workspace = true }` from `[tool.uv.sources]`. Hosting's runtime (app_builder, middleware, manifest, host_cli) no longer needs to import from `simple_module` at runtime — `host_cli` imports stay because they depend on `discover_modules` from core, not anything in `simple_module`. Verify: + +Run: `grep -rn 'from simple_module\b\|import simple_module\b' --include="*.py" framework/hosting/simple_module_hosting/` +Expected: no matches. If anything appears, update it. + +- [ ] **Step 4: Re-sync workspace** + +Run: `uv sync --all-packages` +Expected: succeeds. If `simple_module` is now an unused dep anywhere, the resolver removes it cleanly. + +- [ ] **Step 5: Add the no-framework-deps guard test** + +Create `framework/cli/tests/test_no_framework_deps.py`: + +```python +"""Guard: `simple-module` distribution depends only on typer + tomlkit. + +If a future change accidentally pulls in simple_module_core, FastAPI, +SQLModel, or anything else, this test fires immediately. +""" + +from __future__ import annotations + +from importlib.metadata import distribution + + +def _normalize(req: str) -> str: + """'typer (>=0.12)' -> 'typer'. Strip version specs + extras + spaces.""" + return req.split(";")[0].split("(")[0].split(">=")[0].split(">")[0].split("<")[0].split("==")[0].split("[")[0].strip().lower().replace("_", "-") + + +def test_simple_module_runtime_deps_are_minimal() -> None: + requires = distribution("simple-module").requires or [] + names = {_normalize(r) for r in requires} + # Allowed: declared deps + their transitive obligations are NOT checked here; + # only the direct deps of `simple-module` itself. + assert names == {"typer", "tomlkit"}, ( + f"simple-module direct deps drifted; got {sorted(names)}, " + "expected {'typer', 'tomlkit'}" + ) +``` + +- [ ] **Step 6: Run the dep-guard test** + +Run: `uv run pytest framework/cli/tests/test_no_framework_deps.py -v` +Expected: PASS. + +- [ ] **Step 7: Run the project lint suite** + +Run: `make lint` +Expected: all checks pass — ruff, ty, file-size, biome, tsc, metadata, readmes. + +If file-size fires for any moved file, split it (the moved files all stayed close to their previous sizes; recipes ≈ 95 lines, app_project ≈ 130, etc., well under 300). + +- [ ] **Step 8: Run the full test suite** + +Run: `uv run pytest -q` +Expected: all tests green. The plan's planned test count is roughly: +- Original 1009 tests still passing. +- 47 CLI tests (now under `framework/cli/tests/`). +- 4 plugin discovery tests (new). +- 3 host_cli tests (new). +- 1 no-framework-deps test (new). +- Total expected: ~1064. + +- [ ] **Step 9: Final smoke test of the user-visible CLI** + +Run: +```bash +uv run sm --help +uv run sm host --help +uv run sm users --help +uv run sm settings --help +TMP=$(mktemp -d) && uv run sm new demo --yes --preset full --no-install --dest "$TMP/demo" +ls "$TMP/demo/scripts/run_worker.py" "$TMP/demo/docker-compose.yml" +``` +Expected: every command works; the new project lands all background-task scaffolding correctly. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "refactor(cli): finish the standalone simple-module carve-out + +$(cat <<'EOF' +- Delete the simple_module_hosting.scaffolding shim and the + workspace dep on simple_module. +- Add framework/cli/tests/test_no_framework_deps.py to guard + against future dep drift in the standalone scaffolder. +- README + Makefile reflect the final sm host/users/settings shape. +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Distribution layout (`framework/cli/`, dep tuple typer + tomlkit) → Task 1. +- File moves (`_env`, case helpers, scaffolding, app_project, catalog, wizard, recipes, new, templates) → Tasks 2–4. +- Click → Typer port → Task 5. +- Plugin discovery (`simple_module.cli_plugins`, `discover_and_mount`, error handling, dup detection) → Task 6. +- `host_cli` plugin (gen-pages, sync-js-deps), Makefile rename → Task 7. +- `users` + `settings` plugin migration, drop sm-users/sm-settings scripts → Task 8. +- No-framework-deps guard → Task 9. +- Cleanup, README updates, full verification → Tasks 8 & 9. + +**No placeholders:** every step contains the actual code or command needed. + +**Type consistency:** `app` is the Typer instance everywhere it's referenced; `discover_and_mount(app)` is the same name in `cli.py`, `plugins.py`, and the tests; `_iter_plugin_entries` is the test seam used in both `plugins.py` and `test_plugin_discovery.py`. Case helpers renamed without leading underscores (`to_snake_case` / `to_kebab_case` / `to_pascal_case`) consistently across all modules. + +**Out of scope:** version bumps, release-workflow matrix updates, doc-site changes — all deferred to the public-release plan. diff --git a/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md b/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md new file mode 100644 index 00000000..ed75c67e --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md @@ -0,0 +1,220 @@ +# CLI: project setup with modules and background jobs + +**Date:** 2026-04-26 +**Status:** Draft, pending implementation + +## Problem + +`sm new ` today only pre-wires three modules (`users`, `dashboard`, `permissions`) and ignores the cost of opting in to anything else. To stand up a project that uses `background_tasks`, the user has to: + +1. Add `simple_module_background_tasks` to `pyproject.toml` by hand. +2. Set `SM_BG_TASKS_BROKER_URL` in `.env`. +3. Author a `scripts/run_worker.py` that builds the Celery app. +4. Add `worker` / `beat` / `worker-docker` targets to `Makefile`. +5. Author a `docker-compose.yml` with `redis`, `worker`, and `beat` services and a `worker.Dockerfile`. + +That is enough friction that "I want background jobs" turns into a half-day yak-shave. The same goes, to a lesser extent, for any module outside the hardcoded standard three. We want one command that scaffolds a project with any chosen subset of modules — including background jobs — and produces a runnable project. + +## Goals + +- `sm new` accepts an explicit module list via flags, or runs an interactive wizard when no flags are given. +- Selecting `background_tasks` lands a runnable Celery worker + beat + Redis stack via `docker compose up`, plus host Make targets and `scripts/run_worker.py`, with no manual editing required. +- Module dependencies are resolved transitively and added silently (with a printed note). +- The CLI catalog is hardcoded — adding a new module to the catalog is a CLI code change. +- The framework layer remains free of devex concerns (Makefile / compose). Recipes live in the CLI package. + +## Non-goals + +- Third-party module registration. The catalog is closed for this change. +- A TUI library (`questionary`, `inquirer`, etc.). Wizard uses `click.prompt` and `click.confirm`. +- Worker queue configuration, autoscaling, beat-only deployments. +- Replacing or deprecating `sm create-host` — it remains the lower-level "deps-only" command. + +## Design + +### File layout + +Replace `framework/hosting/simple_module_hosting/cli.py` with a package: + +``` +framework/hosting/simple_module_hosting/cli/ +├── __init__.py # click group; re-exports existing commands +├── new.py # `sm new` — flags + wizard, calls into catalog/wizard/recipes +├── catalog.py # ModuleEntry, CATALOG, PRESETS, expand_deps() +├── wizard.py # interactive prompts (db, tenancy, preset, custom-pick) +└── recipes.py # Recipe protocol + per-module post-scaffold actions +``` + +Each file has one responsibility and stays under the 300-line cap. Existing commands (`create-host`, `create-module`, `gen-pages`, `sync-js-deps`) move into `cli/__init__.py` (or thin per-command modules) so the `sm` console script keeps working. + +### Catalog + +```python +# cli/catalog.py +from dataclasses import dataclass + +@dataclass(frozen=True) +class ModuleEntry: + name: str # "background_tasks" — snake_case key + package: str # "simple_module_background_tasks" + display: str # "Background Tasks" + requires: tuple[str, ...] = () # other catalog keys + recipe: str | None = None # key into RECIPES, or None + +CATALOG: dict[str, ModuleEntry] = { + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), + "permissions": ModuleEntry("permissions", "simple_module_permissions", "Permissions", requires=("auth", "users")), + "dashboard": ModuleEntry("dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products")), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), + "file_storage": ModuleEntry("file_storage", "simple_module_file_storage", "File Storage", requires=("settings",)), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "datasets": ModuleEntry("datasets", "simple_module_datasets", "Datasets", requires=("file_storage", "background_tasks")), + "background_tasks": ModuleEntry("background_tasks", "simple_module_background_tasks", "Background Tasks", requires=("users",), recipe="background_tasks"), +} + +PRESETS: dict[str, tuple[str, ...]] = { + "minimal": ("users",), + "standard": ("users", "dashboard", "permissions"), + "full": tuple(CATALOG), +} + +def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]: + """Return (resolved_topo_order, auto_added_pairs). + auto_added_pairs is [(added_module, required_by), ...] for printing.""" +``` + +`requires=` values mirror each module's real `ModuleMeta.depends_on`, transcribed to the snake_case catalog key. + +`expand_deps` is the only non-trivial function: BFS over `requires`, append-only, preserves topo order so the resulting list is always loadable. Unknown name raises `KeyError("unknown module: ; available: ...")`. + +### `sm new` interface + +``` +sm new + --dest + --db sqlite|postgres (existing) + --tenancy/--no-tenancy (existing) + --preset minimal|standard|full NEW + --with mod1,mod2,... NEW — added on top of preset + --yes/-y (existing — skips wizard, uses defaults) + --no-install (existing) +``` + +Resolution rule: `selected = preset_modules ∪ --with`, then `expand_deps(selected)`. For each `(added, required_by)` pair, print `Added (required by )`. + +Defaults when `--yes` is given without `--preset`/`--with`: preset `standard` (matches today's pre-wired set, no behavior change for existing scripts). + +Conflict rule: if `--with` includes an unknown module name, exit 1 with the available list. If both `--preset` and `--with` are given, both are honored (union). + +### Wizard + +When `--yes` is absent and neither `--preset` nor `--with` is given, prompt in this order using `click.prompt` / `click.confirm`: + +1. `Database backend [sqlite/postgres]:` (default sqlite) +2. `Enable multi-tenancy? [y/N]:` +3. `Preset: [1] minimal [2] standard [3] full [4] custom` (default 2) +4. If choice 4: per-module `Include ? [y/N]:` loop in catalog order. +5. Resolve deps. Print: `Selected modules: ` and any `Added X (required by Y)` lines. +6. `Proceed? [Y/n]:` + +If `--yes` and any of `--preset`/`--with` is given, skip the wizard entirely. + +### Recipes + +```python +# cli/recipes.py +from typing import Protocol +from dataclasses import dataclass +from pathlib import Path + +@dataclass +class ScaffoldCtx: + name: str + db: str + tenancy: bool + selected: tuple[str, ...] + +class Recipe(Protocol): + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... + +RECIPES: dict[str, Recipe] = { + "background_tasks": BackgroundTasksRecipe(), +} +``` + +`BackgroundTasksRecipe.apply` performs: + +1. `_set_env_key(target/".env.example", "SM_BG_TASKS_BROKER_URL", "redis://redis:6379/0")`. +2. Copy `templates/host/_optional/background_tasks/run_worker.py` to `target/scripts/run_worker.py`. +3. Append the `Makefile` snippet (worker / beat / worker-docker targets) — idempotent, skipped if any target already present. +4. Write `target/docker-compose.yml` with `redis`, `worker`, `beat` services. The host template does not ship a `docker-compose.yml`, so the recipe owns the file outright — no YAML merge needed, no `pyyaml` dependency. The compose file is shipped as a static template. +5. Copy `templates/host/_optional/background_tasks/worker.Dockerfile` to `target/docker/worker.Dockerfile`. + +The recipe receives `ScaffoldCtx`. `name` is reused for the compose project name; `db`/`tenancy` are unused for `background_tasks` today but are part of the protocol so future recipes can read them. + +### Templates + +New package-data files alongside the existing host templates: + +``` +framework/hosting/simple_module_hosting/templates/host/_optional/ +└── background_tasks/ + ├── run_worker.py + ├── docker-compose.yml + ├── Makefile.snippet + └── worker.Dockerfile +``` + +The `_optional/` segment is filtered out by `_apply_template_files` so it's not copied during the default `create_host` pass — it's only consumed by recipes. Add an explicit skip for any path containing `/_optional/` in the host-template walker. + +`run_worker.py` is the same shape as the in-tree `scripts/run_worker.py`: + +```python +from background_tasks.celery_app import build_celery +from background_tasks.settings import BackgroundTasksSettings +celery = build_celery(BackgroundTasksSettings()) +``` + +### Wiring into `create_app_project` + +`create_app_project` gains a `selected: Sequence[str] | None = None` parameter (default `None` → `PRESETS["standard"]`). Internally: + +1. Resolve `selected` via `expand_deps`. +2. Build `_APP_PY_DEPS` by mapping each catalog entry to `f"{entry.package}=={_FRAMEWORK_VERSION}"` instead of the current hardcoded list. +3. Call `create_host(target, name=name, modules=[entry.display for entry in resolved])` so the dependency declarations match. +4. After scaffolding, for each entry with a `recipe`, look it up in `RECIPES` and call `apply(target, ctx)`. + +The two existing kwargs — `db` and `tenancy` — keep their current behavior. + +### Backward compat + +- `sm create-host --with=` keeps current "deps-only" behavior unchanged. +- `sm new --yes` with no other flags → standard preset → same outcome as today. +- `create_app_project` callers passing only `name`, `db`, `tenancy` keep working (default `selected=None`). + +## Tests + +- `framework/hosting/tests/test_cli_catalog.py` — `expand_deps` returns transitive closure; auto-add list correct; unknown name raises with available-list message; idempotent on already-resolved input. +- `framework/hosting/tests/test_cli_wizard.py` — `CliRunner` driving each preset path (1/2/3/4) and the custom checkbox loop; verifies dep auto-add notice prints; verifies `--yes` skips prompts. +- `framework/hosting/tests/test_cli_recipes.py` — `BackgroundTasksRecipe.apply` against a fresh tempdir already populated by `create_host`. Asserts `.env.example` contains `SM_BG_TASKS_BROKER_URL`, `scripts/run_worker.py` exists with the expected import, `Makefile` contains `worker:` / `beat:` targets, `docker-compose.yml` parses to YAML with `redis`, `worker`, `beat` keys under `services`. +- `framework/hosting/tests/test_cli_new.py` — end-to-end smoke: `sm new demo --yes --preset=full --no-install --dest=` produces a project that contains the union of all modules, a runnable compose file, Make targets, and `pyproject.toml` listing every package. + +## Failure modes + +- **Existing files when applying recipes.** `Makefile` and `.env.example` are edited idempotently (skip if marker present). `docker-compose.yml`, `scripts/run_worker.py`, and `docker/worker.Dockerfile` are written outright; if any already exist when the recipe runs, the recipe errors out — collision means user-authored content from a previous scaffold attempt and we don't clobber. +- **Catalog drift.** A unit test loads every package referenced in the catalog (by import name) and asserts the Python distribution exists in the workspace. Catches typos and renames at CI time, not at user-runtime. +- **`--with` typos.** Print available module list with the closest match (`difflib.get_close_matches`). + +## Migration + +This change is additive: + +- `cli.py` becomes `cli/__init__.py` plus four new files. The `sm` console-script entry point in `framework/hosting/pyproject.toml` continues to point at `simple_module_hosting.cli:main` — `__init__.py` exposes `main` from the new package. +- No template changes that affect existing host scaffolds. The new `_optional/` tree is additive. +- `create_app_project`'s positional/kwarg signature is unchanged; only the new `selected=` kwarg is added. + +## Open questions + +None blocking. The catalog `requires=` mappings will be re-verified against each module's real `ModuleMeta.depends_on` during implementation; the values in the Catalog section above are the current best mapping and may be tightened. diff --git a/docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md b/docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md new file mode 100644 index 00000000..bf0f8c3a --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md @@ -0,0 +1,201 @@ +# Standalone `simple-module` CLI distribution + +**Date:** 2026-04-26 +**Status:** Draft, pending implementation + +## Problem + +`sm new` is currently shipped inside `simple_module_hosting`, which means `pip install simple-module-hosting` (or anything on top of it like `pipx install`) drags in FastAPI, Starlette, Inertia, Uvicorn, SQLModel, and the rest of the runtime — even when the user only wants to scaffold a new project. There is also no single CLI surface: hosting registers `sm`, `simple_module_hosting`'s helpers (`gen-pages`, `sync-js-deps`) are crammed into the same binary, and individual modules register their own `sm-users`, `sm-settings`, … console scripts. A new user who runs `pip install simple-module` should get a small, runnable scaffolder; a developer working inside a project should get one consolidated `sm` whose subcommand surface grows as plugins are installed. + +## Goals + +- A new PyPI distribution `simple-module` whose only runtime dependencies are `typer` and `tomlkit`. No framework-runtime deps. +- A single `sm` console script. All today's `sm-*` scripts (`sm-host`, `sm-users`, `sm-settings`) go away. +- Built-in (always-available) commands: `sm new`, `sm create-host`, `sm create-module`. +- Plugin commands provided via Python entry points: `simple_module_hosting` contributes `sm host gen-pages` and `sm host sync-js-deps`; the `users` and `settings` modules contribute `sm users …` and `sm settings …`. +- `pip install simple-module` works on a machine with no other framework packages installed and gives the user a working scaffolder. + +## Non-goals + +- Bumping versions, cutting a release, or interacting with `release.yml` (covered by the existing public-release spec). +- Reorganizing internal module-CLI behavior beyond the necessary `Typer` shape changes. +- Documentation rewrites beyond the README install snippet and the Makefile. +- A namespace or grouping convention for future module CLIs beyond what entry points + Typer naturally give. + +## Design + +### Distribution layout + +``` +framework/cli/ ← NEW workspace member +├── pyproject.toml name="simple-module" +├── README.md +├── LICENSE +└── simple_module/ importable package + ├── __init__.py + ├── _env.py set_env_key + ├── case.py _to_snake_case / _to_kebab_case / _to_pascal_case + ├── scaffolding.py create_host, create_module, _apply_template_files + ├── app_project.py create_app_project + helpers + ├── catalog.py ModuleEntry, CATALOG, PRESETS, expand_deps + ├── wizard.py run_wizard + ├── recipes.py Recipe protocol, BackgroundTasksRecipe, RECIPES + ├── new.py `sm new` Typer command + ├── plugins.py entry-point discovery + mounting + ├── cli.py root Typer app, mounts plugins, exposes `main` + └── templates/ package data + ├── host/ + ├── module/ + └── host/_optional/background_tasks/ +``` + +`framework/cli/pyproject.toml`: + +```toml +[project] +name = "simple-module" +version = "0.0.1" +description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +keywords = ["simple-module", "scaffolding", "cli"] +dependencies = [ + "typer>=0.12", + "tomlkit>=0.13", +] + +[project.scripts] +sm = "simple_module.cli:main" +simple-module = "simple_module.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["simple_module"] +``` + +### Plugin entry-point contract + +Group: `simple_module.cli_plugins`. + +Each plugin's `pyproject.toml` adds an entry under that group whose **key** becomes the subcommand namespace and whose **value** is a `module:attr` reference to a `typer.Typer` instance: + +```toml +# framework/hosting/pyproject.toml +[project.entry-points."simple_module.cli_plugins"] +host = "simple_module_hosting.host_cli:app" + +# modules/users/pyproject.toml +[project.entry-points."simple_module.cli_plugins"] +users = "users.cli:app" + +# modules/settings/pyproject.toml +[project.entry-points."simple_module.cli_plugins"] +settings = "settings.cli:app" +``` + +At startup, `simple_module.plugins.discover()` calls `importlib.metadata.entry_points(group="simple_module.cli_plugins")`, loads each entry, and mounts it on the root app via `root.add_typer(plugin_app, name=entry.name)`. A failed entry-point load (broken import, missing attribute) prints a single warning and is skipped — `sm` itself keeps working. Discovery is unconditional but cheap; loading is eager so `--help` shows the full surface. + +Resulting CLI surface: + +``` +sm new … (built-in) +sm create-host … (built-in) +sm create-module … (built-in) +sm host gen-pages … (when simple_module_hosting installed) +sm host sync-js-deps … (when simple_module_hosting installed) +sm users create-admin … (when users module installed) +sm settings import-from-env … (when settings module installed) +``` + +### File moves + +| From | To | +|---|---| +| `framework/hosting/simple_module_hosting/_env.py` | `framework/cli/simple_module/_env.py` | +| `framework/hosting/simple_module_hosting/scaffolding.py` (`create_host`, `create_module`, `_apply_template_files`, `_iter_template_files`, `_resolve_template_root`, etc.) | `framework/cli/simple_module/scaffolding.py` | +| `_to_snake_case` / `_to_kebab_case` / `_to_pascal_case` (currently in `scaffolding.py`) | `framework/cli/simple_module/case.py` | +| `framework/hosting/simple_module_hosting/app_project.py` | `framework/cli/simple_module/app_project.py` | +| `framework/hosting/simple_module_hosting/cli/{catalog,wizard,recipes,new}.py` | `framework/cli/simple_module/{catalog,wizard,recipes,new}.py` | +| `framework/hosting/simple_module_hosting/cli/__init__.py` (only the `create-host` and `create-module` halves) | `framework/cli/simple_module/cli.py` | +| `framework/hosting/simple_module_hosting/cli/__init__.py` (only the `gen-pages` and `sync-js-deps` halves) | `framework/hosting/simple_module_hosting/host_cli.py` (Typer `app`) | +| `framework/hosting/simple_module_hosting/templates/{host,module,host/_optional}/` | `framework/cli/simple_module/templates/{host,module,host/_optional}/` | +| `framework/hosting/tests/test_cli_*.py` | `framework/cli/tests/test_cli_*.py` (imports update from `simple_module_hosting.cli.*` → `simple_module.*`) | +| `framework/hosting/tests/test_scaffolding_host.py` | `framework/cli/tests/test_scaffolding.py` | + +The `simple_module_hosting` package keeps its **runtime** (`app_builder.py`, middleware, `Settings`, `health.py`, manifest helpers, etc.) and gains `host_cli.py`. It loses `cli/`, `app_project.py`, `scaffolding.py`, `_env.py`, and the `templates/` tree. + +### Code-level changes + +Every Click decorator in the moved code is rewritten to Typer's signature style. Concretely: + +- `@click.command(...)` + `@click.option(...)` decorators → `def cmd(arg: Annotated[T, typer.Option(...)] = default): ...` registered via `@app.command()`. +- `click.Choice(["sqlite", "postgres"])` → string `Enum` (e.g. `class Db(str, Enum): sqlite = "sqlite"; postgres = "postgres"`) referenced as the parameter type. Typer renders the same shell-completion + help. +- `click.prompt`, `click.confirm`, `click.echo`, `click.Abort` → `typer.prompt`, `typer.confirm`, `typer.echo`, `typer.Abort` (re-exports of the Click implementations; behavior identical). +- Tests: `from click.testing import CliRunner` → `from typer.testing import CliRunner`. Same `runner.invoke(...)` / `input=` API. + +The interactive wizard logic, `expand_deps`, the recipe protocol, the catalog data, and the templates are unchanged. + +### Module-CLI updates + +`modules/users/users/cli.py` is already a Typer app exporting `app`; only the `pyproject.toml` script declaration changes: + +```toml +# Before +[project.scripts] +sm-users = "users.cli:app" + +# After +[project.entry-points."simple_module.cli_plugins"] +users = "users.cli:app" +``` + +`modules/settings/settings/cli.py` is currently Click-style with a `main` function. Convert to a Typer app named `app`. Same pyproject.toml swap (`sm-settings` script → entry-point under `simple_module.cli_plugins`). + +`framework/hosting/pyproject.toml` drops `sm = "simple_module_hosting.cli:main"` and gains: + +```toml +[project.entry-points."simple_module.cli_plugins"] +host = "simple_module_hosting.host_cli:app" +``` + +Note: the `simple-module` console-script alias is dropped from `simple_module_hosting` too — it now lives only in the `simple-module` package. + +### Workspace + Makefile wiring + +- Root `pyproject.toml`: add `framework/cli` to workspace members. +- `[tool.uv.sources]`: add `simple-module = { workspace = true }` so in-tree dev resolves to the local copy. +- `host/pyproject.toml`: add `simple-module==0.0.1` as a dependency. (Hosting and module packages do **not** depend on `simple-module`.) +- `Makefile`: rename `sm gen-pages` → `sm host gen-pages` and `sm sync-js-deps` → `sm host sync-js-deps` in the `gen-pages` and `sync-module-deps` targets. The `make new-module` target keeps invoking `sm create-module`. +- Existing CI (`.github/workflows/pr.yml`) needs no change — `make lint` and `make test` will pick up the new workspace member automatically. +- Release matrix (`.github/workflows/release.yml`): one new entry for the `simple-module` package alongside the existing 14. + +### Test strategy + +- The 47 CLI tests move from `framework/hosting/tests/test_cli_*.py` → `framework/cli/tests/test_cli_*.py`. Imports update mechanically (find/replace on `simple_module_hosting.cli` → `simple_module`, `simple_module_hosting.scaffolding` → `simple_module.scaffolding`). The Click `CliRunner` import becomes `from typer.testing import CliRunner`. The Click `result.output` / `result.exit_code` API is identical under Typer's runner. +- `framework/cli/tests/test_no_framework_deps.py` (new): asserts that `importlib.metadata.requires("simple-module")` is exactly `["typer>=0.12", "tomlkit>=0.13"]`. Guards against accidental dep drift introducing `simple_module_core` / `simple_module_hosting` deps. +- `framework/cli/tests/test_plugin_discovery.py` (new): exercises `simple_module.plugins.discover()` against a fake entry-point group injected via `monkeypatch.setattr(importlib.metadata, "entry_points", ...)`. Asserts that valid plugins mount, broken plugins are skipped with a warning, and the resulting Typer app's `--help` lists the expected subgroups. +- `framework/hosting/tests/test_host_cli.py` (new): smoke-tests `sm-host`'s `gen-pages` and `sync-js-deps` happy paths after the move. The existing `test_scaffolding_host.py` becomes `framework/cli/tests/test_scaffolding.py`. +- `framework/hosting/tests/test_app.py` etc. unchanged. + +### Failure modes + +- **A plugin's entry point references a missing module or non-Typer object.** Log a warning (`sm` keeps working with whatever else is installed). Test covers this. +- **Two plugins claim the same subgroup name** (e.g. two installs both register `host`). `add_typer` raises; we catch the second registration and warn, keeping the first. Test covers this. +- **A user has `simple-module` installed but no plugins.** Built-in commands work; `sm --help` shows only `new` / `create-host` / `create-module`. No errors. +- **Templates package-data shipping.** The `_optional/` directory tree is copied as `package-data` under the wheel; verified by `framework/cli/tests/test_cli_recipes.py` reading the package-data path at runtime (same pattern that works today). + +### Backward compat + +This is a pre-`0.0.1` framework with no external consumers. There are no shims or aliases: + +- `simple_module_hosting.cli` package, `simple_module_hosting.scaffolding`, and `simple_module_hosting.app_project` are **deleted**, not re-exported. +- All in-tree callers are updated mechanically: tests, the `Makefile`, `host/pyproject.toml`, the per-module `pyproject.toml` files. +- The `sm-users` and `sm-settings` console scripts are deleted. Anyone running `sm-users create-admin` switches to `sm users create-admin`. + +## Open questions + +None blocking. The dep guard test (`test_no_framework_deps.py`) is intentionally narrow: it pins the declared dep list to exactly `typer` and `tomlkit`. If we ever decide to relax to `typer-slim` to drop `rich`, that's a single-line change with the test caught the moment. diff --git a/framework/cli/LICENSE b/framework/cli/LICENSE new file mode 100644 index 00000000..a1e1d361 --- /dev/null +++ b/framework/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Anto Subash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/framework/cli/README.md b/framework/cli/README.md new file mode 100644 index 00000000..fdac1d5e --- /dev/null +++ b/framework/cli/README.md @@ -0,0 +1,38 @@ +# simple_module_cli + +Standalone scaffolder for the [SimpleModule framework](https://github.com/antosubash/simple_module_python). + +## Install + +```bash +pip install simple_module_cli +# or, to keep the CLI in its own venv: +pipx install simple_module_cli +# or, to run it without installing: +uvx --from simple_module_cli sm new my-app +``` + +The package depends only on `typer` and `tomlkit` — installing it does **not** pull in FastAPI, SQLModel, or any other framework runtime. + +## Usage + +```bash +sm new my-app # interactive wizard +sm new my-app --yes --preset full # all built-in modules + background jobs +sm create-module my_feature # scaffold a publishable module package +sm create-host bare-host # scaffold a bare host (no opinionated wiring) +``` + +Built-in commands: `sm new`, `sm create-host`, `sm create-module`. + +When other framework packages are installed, they contribute additional subcommands via the `simple_module_cli.cli_plugins` entry-point group: + +| Package | Commands | +|---|---| +| `simple_module_hosting` | `sm host gen-pages`, `sm host sync-js-deps` | +| `simple_module_users` | `sm users create-admin` | +| `simple_module_settings` | `sm settings import-from-env` | + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/framework/cli/pyproject.toml b/framework/cli/pyproject.toml new file mode 100644 index 00000000..681d0420 --- /dev/null +++ b/framework/cli/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "simple_module_cli" +version = "0.0.1" +description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "scaffolding", "cli", "fastapi", "modular-monolith"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "typer>=0.12", + "tomlkit>=0.13", +] + +[project.scripts] +sm = "simple_module_cli.cli:main" +simple-module = "simple_module_cli.cli:main" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" +Issues = "https://github.com/antosubash/simple_module_python/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["simple_module_cli"] + +[tool.hatch.build.targets.wheel.shared-data] +"simple_module_cli/templates" = "simple_module_cli/templates" diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/__init__.py b/framework/cli/simple_module_cli/__init__.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/__init__.py rename to framework/cli/simple_module_cli/__init__.py diff --git a/framework/cli/simple_module_cli/_env.py b/framework/cli/simple_module_cli/_env.py new file mode 100644 index 00000000..58cf7548 --- /dev/null +++ b/framework/cli/simple_module_cli/_env.py @@ -0,0 +1,12 @@ +"""Shared helpers for editing dotenv-style files at scaffold time.""" + +from __future__ import annotations + +__all__ = ["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" diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py new file mode 100644 index 00000000..3662ba50 --- /dev/null +++ b/framework/cli/simple_module_cli/app_project.py @@ -0,0 +1,123 @@ +"""Greenfield ``simple-module new`` scaffolding. + +Wraps :func:`simple_module_hosting.scaffolding.create_host` with the +opinionated bits — module-list resolution from the CLI catalog, secret +generation, DB URL selection, ``pyproject.toml`` / ``package.json`` +rewriting, and post-scaffold recipe application. + +Lives in its own module to keep ``scaffolding.py`` under the per-file +line cap and to make the surface area of "host scaffold" vs "app +scaffold" obvious to readers. +""" + +from __future__ import annotations + +import json as _json +import secrets as _secrets +from collections.abc import Sequence +from pathlib import Path + +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.catalog import CATALOG, PRESETS, expand_deps +from simple_module_cli.recipes import RECIPES, ScaffoldCtx +from simple_module_cli.scaffolding import create_host + +__all__ = ["create_app_project"] + +_FRAMEWORK_VERSION = "0.0.1" + +_APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"] + +_APP_NPM_DEPS = { + "@simple-module-py/ui": _FRAMEWORK_VERSION, + "@simple-module-py/i18n": _FRAMEWORK_VERSION, + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^1.0.0", +} +_APP_NPM_DEV_DEPS = { + "@simple-module-py/tsconfig": _FRAMEWORK_VERSION, + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.6.0", + "vite": "^8.0.0", +} + + +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> None: + """Greenfield ``simple-module new`` scaffold. + + Wraps :func:`create_host` with a chosen module list (defaults to the + ``standard`` preset), generates a secret, picks a DB URL, rewrites + the generated ``package.json`` / ``pyproject.toml`` to pin exact + framework versions, and applies any matching post-scaffold recipes + (e.g. the ``background_tasks`` recipe drops a Celery worker stack). + """ + if target.exists() and any(target.iterdir()): + raise FileExistsError( + f"Destination {target} already exists and is non-empty; " + "choose a new path or remove its contents first." + ) + + chosen = list(selected) if selected is not None else list(PRESETS["standard"]) + resolved, _added = expand_deps(chosen) + + display_names = [to_pascal_case(CATALOG[m].display) for m in resolved] + create_host(target, name=name, modules=display_names) + + py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ + f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved + ] + + 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)) + env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name))) + env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false") + env_path.write_text(env_text, encoding="utf-8") + + pyproject = target / "pyproject.toml" + if pyproject.exists(): + text = pyproject.read_text(encoding="utf-8") + text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS) + pyproject.write_text(text, encoding="utf-8") + + pkg_path = target / "package.json" + if pkg_path.exists(): + data = _json.loads(pkg_path.read_text(encoding="utf-8")) + else: + data = {"name": to_kebab_case(name), "private": True, "type": "module"} + data.setdefault("dependencies", {}).update(_APP_NPM_DEPS) + data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) + pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") + + ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) + for mod_name in resolved: + recipe_key = CATALOG[mod_name].recipe + if recipe_key is not None and recipe_key in RECIPES: + RECIPES[recipe_key].apply(target, ctx) + + +def _db_url(db: str, slug: str) -> str: + if db == "postgres": + return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" + return "sqlite+aiosqlite:///./app.db" + + +def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str: + """Replace project.dependencies + dependency-groups.dev in a pyproject.toml.""" + import tomlkit + + doc = tomlkit.parse(text) + project = doc.setdefault("project", tomlkit.table()) + project["dependencies"] = list(deps) + groups = doc.setdefault("dependency-groups", tomlkit.table()) + groups["dev"] = list(dev_deps) + return tomlkit.dumps(doc) diff --git a/framework/cli/simple_module_cli/case.py b/framework/cli/simple_module_cli/case.py new file mode 100644 index 00000000..527626bc --- /dev/null +++ b/framework/cli/simple_module_cli/case.py @@ -0,0 +1,30 @@ +"""Identifier case-conversion helpers used by every scaffolder. + +Module/host names are accepted in any case style and normalized to the +three forms the templates need: snake_case (Python package + entry-point +key), kebab-case (PyPI slug), and PascalCase (display name in Meta). +""" + +from __future__ import annotations + +import re + +__all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"] + + +def to_snake_case(name: str) -> str: + """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'.""" + s = re.sub(r"(? str: + """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug).""" + return to_snake_case(name).replace("_", "-") + + +def to_pascal_case(name: str) -> str: + """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta).""" + snake = to_snake_case(name) + return "".join(part.capitalize() for part in snake.split("_") if part) diff --git a/framework/cli/simple_module_cli/catalog.py b/framework/cli/simple_module_cli/catalog.py new file mode 100644 index 00000000..ad436ff5 --- /dev/null +++ b/framework/cli/simple_module_cli/catalog.py @@ -0,0 +1,107 @@ +"""Hardcoded catalog of installable SimpleModule modules. + +Each :class:`ModuleEntry` declares the PyPI package name, a human display +name, transitive ``requires`` (other catalog keys), and an optional +``recipe`` key for post-scaffold actions handled by :mod:`.recipes`. + +:func:`expand_deps` takes a user-selected subset and returns a +topologically ordered superset including every transitive requirement, +plus the list of ``(added, required_by)`` pairs for printing back to the +user. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +__all__ = ["CATALOG", "PRESETS", "ModuleEntry", "expand_deps"] + + +@dataclass(frozen=True) +class ModuleEntry: + name: str + package: str + display: str + requires: tuple[str, ...] = field(default_factory=tuple) + recipe: str | None = None + + +CATALOG: dict[str, ModuleEntry] = { + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), + "permissions": ModuleEntry( + "permissions", + "simple_module_permissions", + "Permissions", + requires=("auth", "users"), + ), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "dashboard": ModuleEntry( + "dashboard", + "simple_module_dashboard", + "Dashboard", + requires=("users", "products"), + ), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), + "file_storage": ModuleEntry( + "file_storage", + "simple_module_file_storage", + "File Storage", + requires=("settings",), + ), + "background_tasks": ModuleEntry( + "background_tasks", + "simple_module_background_tasks", + "Background Tasks", + requires=("users",), + recipe="background_tasks", + ), + "datasets": ModuleEntry( + "datasets", + "simple_module_datasets", + "Datasets", + requires=("file_storage", "background_tasks"), + ), +} + + +PRESETS: dict[str, tuple[str, ...]] = { + "minimal": ("users",), + "standard": ("users", "dashboard", "permissions"), + "full": tuple(CATALOG), +} + + +def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]: + """Return ``(topo-ordered resolved list, [(added, required_by), ...])``. + + Raises :class:`KeyError` if any input name is missing from the + catalog. The error message lists the available catalog keys so a + user typo (`--with=does_not_exist`) is self-correcting. + """ + selected_list = list(selected) + for name in selected_list: + if name not in CATALOG: + available = ", ".join(sorted(CATALOG)) + raise KeyError(f"unknown module: {name!r}; available: {available}") + + explicit = set(selected_list) + resolved: list[str] = [] + in_resolved: set[str] = set() + added: list[tuple[str, str]] = [] + + def _visit(name: str, required_by: str | None) -> None: + if name in in_resolved: + return + for dep in CATALOG[name].requires: + _visit(dep, required_by=name) + resolved.append(name) + in_resolved.add(name) + if required_by is not None and name not in explicit: + added.append((name, required_by)) + + for name in selected_list: + _visit(name, required_by=None) + return resolved, added diff --git a/framework/cli/simple_module_cli/cli.py b/framework/cli/simple_module_cli/cli.py new file mode 100644 index 00000000..4792dad7 --- /dev/null +++ b/framework/cli/simple_module_cli/cli.py @@ -0,0 +1,104 @@ +"""Root `sm` Typer app — scaffolders + plugin mount. + +Built-in commands: + sm new + sm create-host + sm create-module + +Plugins discovered via the ``simple_module_cli.cli_plugins`` entry-point +group are mounted as named subgroups (e.g. ``sm host gen-pages``). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from simple_module_cli.case import to_kebab_case +from simple_module_cli.new import new_project +from simple_module_cli.plugins import discover_and_mount +from simple_module_cli.scaffolding import create_host as _create_host +from simple_module_cli.scaffolding import create_module as _create_module + +app = typer.Typer( + help="SimpleModule developer CLI.", + no_args_is_help=True, + add_completion=False, +) + +app.command("new")(new_project) + + +@app.command("create-host") +def create_host( + name: Annotated[str, typer.Argument(help="Host project name.")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + modules: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated module names to declare as deps (e.g. Auth,Products).", + ), + ] = "", +) -> None: + """Scaffold a new SimpleModule host project at ./.""" + target = dest or Path.cwd() / name + selected = [m.strip() for m in modules.split(",") if m.strip()] + try: + _create_host(target, name=name, modules=selected) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created host '{name}' at {target}") + if selected: + typer.echo(f"Declared modules: {', '.join(selected)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + typer.echo(" uv sync") + typer.echo(" cp .env.example .env") + typer.echo(' alembic revision --autogenerate -m "initial schema"') + typer.echo(" alembic upgrade head") + typer.echo(" python main.py") + + +@app.command("create-module") +def create_module( + name: Annotated[str, typer.Argument(help="Module name (any case).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination dir. Defaults to ./simple_module_."), + ] = None, +) -> None: + """Scaffold a publishable SimpleModule module package.""" + slug = to_kebab_case(name) + package = slug.replace("-", "_") + target = dest or Path.cwd() / f"simple_module_{package}" + try: + _create_module(target, name=name) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created module 'simple_module_{package}' at {target}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + typer.echo(" uv sync --extra dev") + typer.echo(" uv run pytest") + + +discover_and_mount(app) + + +def main() -> None: + """Entry point for the `sm` console script.""" + app() + + +if __name__ == "__main__": + main() diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py new file mode 100644 index 00000000..c9295e92 --- /dev/null +++ b/framework/cli/simple_module_cli/new.py @@ -0,0 +1,124 @@ +"""``sm new`` Typer command — flag-driven or interactive scaffolder.""" + +from __future__ import annotations + +import subprocess +from enum import StrEnum +from pathlib import Path +from typing import Annotated + +import typer + +from simple_module_cli.app_project import create_app_project +from simple_module_cli.catalog import PRESETS, expand_deps +from simple_module_cli.wizard import run_wizard + +__all__ = ["new_project"] + + +class Db(StrEnum): + sqlite = "sqlite" + postgres = "postgres" + + +class Preset(StrEnum): + minimal = "minimal" + standard = "standard" + full = "full" + + +def new_project( + name: Annotated[str, typer.Argument(help="App name (used for directory + package).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + db: Annotated[ + Db, + typer.Option("--db", help="Database backend to configure in .env.example."), + ] = Db.sqlite, + tenancy: Annotated[ + bool, + typer.Option("--tenancy/--no-tenancy", help="Enable the multi-tenant middleware."), + ] = False, + preset: Annotated[ + Preset | None, + typer.Option("--preset", help="Module preset. Combine with --with."), + ] = None, + extra: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated extra modules (e.g. background_tasks,file_storage).", + ), + ] = "", + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip interactive prompts; accept defaults."), + ] = False, + no_install: Annotated[ + bool, + typer.Option( + "--no-install", + help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", + ), + ] = False, +) -> None: + """Scaffold a new SimpleModule app, optionally with background jobs.""" + target = dest or Path.cwd() / name + extra_list = [m.strip() for m in extra.split(",") if m.strip()] + flag_driven = preset is not None or bool(extra_list) + + if yes or flag_driven: + chosen = list(PRESETS[(preset or Preset.standard).value]) + extra_list + try: + resolved, added = expand_deps(chosen) + except KeyError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + for added_name, required_by in added: + typer.echo(f"Added {added_name} (required by {required_by})") + db_final, tenancy_final = db.value, tenancy + else: + try: + db_final, tenancy_final, resolved = run_wizard( + default_db=db.value, default_tenancy=tenancy + ) + except typer.Abort: + typer.echo("Aborted.", err=True) + raise typer.Exit(code=1) from None + + try: + create_app_project(target, name=name, db=db_final, tenancy=tenancy_final, selected=resolved) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created app '{name}' at {target}") + typer.echo(f"Modules: {', '.join(resolved)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + if no_install: + typer.echo(" uv sync") + typer.echo(" npm install") + typer.echo(" alembic upgrade head") + typer.echo(" make dev") + if "background_tasks" in resolved: + typer.echo(" docker compose up -d redis worker beat # background jobs") + return + + typer.echo("Installing dependencies...") + for cmd in (["uv", "sync"], ["npm", "install"]): + result = subprocess.run(cmd, cwd=target, check=False) + if result.returncode != 0: + typer.echo( + f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " + "finish setup manually.", + err=True, + ) + return + + subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) + typer.echo("\nSetup complete. Run `make dev` in the new directory.") + if "background_tasks" in resolved: + typer.echo("For background jobs, also run: docker compose up -d redis worker beat") diff --git a/framework/cli/simple_module_cli/plugins.py b/framework/cli/simple_module_cli/plugins.py new file mode 100644 index 00000000..be340876 --- /dev/null +++ b/framework/cli/simple_module_cli/plugins.py @@ -0,0 +1,56 @@ +"""Plugin discovery for ``sm`` via the ``simple_module_cli.cli_plugins`` group. + +Each entry-point's value (``module:attr``) must resolve to a +:class:`typer.Typer` instance. The entry-point name becomes the +subcommand namespace under ``sm`` (e.g. ``sm host gen-pages``). + +Failed loads (broken import, wrong type) print one line to stderr and +are skipped — ``sm`` keeps working with whatever else loads. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterator +from importlib.metadata import EntryPoint, entry_points + +import typer + +__all__ = ["discover_and_mount"] + +_GROUP = "simple_module_cli.cli_plugins" + + +def _iter_plugin_entries() -> Iterator[EntryPoint]: + """Indirection point for tests to inject fake entry points.""" + yield from entry_points(group=_GROUP) + + +def discover_and_mount(root: typer.Typer) -> None: + """Mount every installed plugin under its entry-point name.""" + seen: set[str] = set() + for entry in _iter_plugin_entries(): + if entry.name in seen: + print( + f"[simple-module] duplicate plugin subgroup '{entry.name}' " + f"from {entry.value!r}; keeping first registration.", + file=sys.stderr, + ) + continue + try: + plugin_app = entry.load() + except Exception as exc: + print( + f"[simple-module] failed to load plugin '{entry.name}' ({entry.value}): {exc}", + file=sys.stderr, + ) + continue + if not isinstance(plugin_app, typer.Typer): + print( + f"[simple-module] plugin '{entry.name}' did not export a " + f"typer.Typer instance (got {type(plugin_app).__name__}); skipping.", + file=sys.stderr, + ) + continue + root.add_typer(plugin_app, name=entry.name) + seen.add(entry.name) diff --git a/framework/cli/simple_module_cli/recipes.py b/framework/cli/simple_module_cli/recipes.py new file mode 100644 index 00000000..31aed925 --- /dev/null +++ b/framework/cli/simple_module_cli/recipes.py @@ -0,0 +1,93 @@ +"""Per-module post-scaffold recipes. + +A recipe is invoked by ``sm new`` after the base host scaffold lands. It +performs module-specific actions (write helper scripts, append Make +targets, drop a docker-compose stack). The framework layer is kept free +of devex concerns — recipes know about Makefiles and compose, framework +scaffolding does not. +""" + +from __future__ import annotations + +import importlib.resources +import shutil +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from simple_module_cli._env import set_env_key + +__all__ = [ + "RECIPES", + "BackgroundTasksRecipe", + "Recipe", + "ScaffoldCtx", +] + +_BG_BROKER_ENV_KEY = "SM_BG_TASKS_BROKER_URL" +_BG_BROKER_DEFAULT = "redis://redis:6379/0" +_MAKEFILE_MARKER = "# --- background_tasks --" + + +@dataclass(frozen=True) +class ScaffoldCtx: + name: str + db: str + tenancy: bool + selected: Sequence[str] + + +class Recipe(Protocol): + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... + + +def _optional_template_root(name: str) -> Path: + """Resolve ``templates/host/_optional//`` from package data.""" + base = importlib.resources.files("simple_module_cli") + return Path(str(base / "templates" / "host" / "_optional" / name)) + + +class BackgroundTasksRecipe: + """Lays down run_worker.py + compose + Dockerfile + Make targets.""" + + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: + templates = _optional_template_root("background_tasks") + + run_worker_dest = target / "scripts" / "run_worker.py" + compose_dest = target / "docker-compose.yml" + dockerfile_dest = target / "docker" / "worker.Dockerfile" + + for path in (run_worker_dest, compose_dest, dockerfile_dest): + if path.exists(): + raise FileExistsError( + f"{path} already exists — refusing to clobber. " + "Remove the file or run `sm new` against an empty directory." + ) + + run_worker_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "run_worker.py", run_worker_dest) + + shutil.copy2(templates / "docker-compose.yml", compose_dest) + + dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest) + + env_path = target / ".env.example" + env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + env_path.write_text( + set_env_key(env_text, _BG_BROKER_ENV_KEY, _BG_BROKER_DEFAULT), + encoding="utf-8", + ) + + makefile_path = target / "Makefile" + snippet = (templates / "Makefile.snippet").read_text(encoding="utf-8") + existing = makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else "" + if _MAKEFILE_MARKER not in existing: + sep = "" if existing.endswith("\n") or not existing else "\n" + makefile_path.write_text(existing + sep + snippet, encoding="utf-8") + + +RECIPES: dict[str, Recipe] = { + "background_tasks": BackgroundTasksRecipe(), +} diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py new file mode 100644 index 00000000..ae877e90 --- /dev/null +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -0,0 +1,124 @@ +"""Host + module scaffolding via package-data templates. + +* :func:`create_host` materializes a new host project from the templates + under ``simple_module/templates/host/``. +* :func:`create_module` materializes a new module package from + ``simple_module/templates/module/``. + +The frontend pages manifest + per-module JS dep discovery live in +:mod:`simple_module_hosting.manifest` (those need module-discovery and +stay in hosting). +""" + +from __future__ import annotations + +import importlib.resources +import logging +import shutil +from collections.abc import Mapping, Sequence +from pathlib import Path + +from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case + +__all__ = ["create_host", "create_module"] + +logger = logging.getLogger(__name__) + +_TEMPLATES_PACKAGE = "simple_module_cli.templates" +_PACKAGE_PATH_TOKEN = "__PACKAGE__" + + +def _module_to_pypi_name(name: str) -> str: + return f"simple_module_{name.lower()}" + + +def _iter_template_files(template_root: Path): + """Yield every file under ``template_root``. Skips ``_optional/`` paths.""" + for path in template_root.rglob("*"): + if not path.is_file(): + continue + if "_optional" in path.relative_to(template_root).parts: + continue + yield path + + +def _require_empty_dest(dest: Path) -> None: + if dest.exists() and any(dest.iterdir()): + raise FileExistsError( + f"Destination {dest} already exists and is non-empty. " + "Choose a new path or remove the contents first." + ) + dest.mkdir(parents=True, exist_ok=True) + + +def _resolve_template_root(subdir: str, override: Path | None) -> Path: + if override is not None: + return Path(override) + return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir)) + + +def _apply_template_files( + src_root: Path, + dest: Path, + substitutions: Mapping[str, str], + *, + path_rewrites: Mapping[str, str] | None = None, +) -> None: + for src in _iter_template_files(src_root): + rel_str = str(src.relative_to(src_root)) + for old, new in (path_rewrites or {}).items(): + rel_str = rel_str.replace(old, new) + rel_str = rel_str.removesuffix(".tpl") + target = dest / rel_str + target.parent.mkdir(parents=True, exist_ok=True) + if src.suffix == ".tpl": + text = src.read_text(encoding="utf-8") + for placeholder, value in substitutions.items(): + text = text.replace(placeholder, value) + target.write_text(text, encoding="utf-8") + else: + shutil.copy2(src, target) + + +def create_host( + dest: Path, + name: str, + modules: Sequence[str], + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules) + _apply_template_files( + _resolve_template_root("host", template_root), + dest, + {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines}, + ) + logger.info( + "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" + ) + return dest + + +def create_module( + dest: Path, + name: str, + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + display_name = to_pascal_case(name) + slug = to_kebab_case(name) + package_name = to_snake_case(name) + _apply_template_files( + _resolve_template_root("module", template_root), + dest, + substitutions={ + "{{MODULE_NAME}}": display_name, + "{{MODULE_SLUG}}": slug, + "{{PACKAGE_NAME}}": package_name, + }, + path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, + ) + logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name) + return dest diff --git a/framework/hosting/simple_module_hosting/templates/host/.env.example b/framework/cli/simple_module_cli/templates/host/.env.example similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/.env.example rename to framework/cli/simple_module_cli/templates/host/.env.example diff --git a/framework/hosting/simple_module_hosting/templates/host/.gitignore b/framework/cli/simple_module_cli/templates/host/.gitignore similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/.gitignore rename to framework/cli/simple_module_cli/templates/host/.gitignore diff --git a/framework/hosting/simple_module_hosting/templates/host/Makefile b/framework/cli/simple_module_cli/templates/host/Makefile similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/Makefile rename to framework/cli/simple_module_cli/templates/host/Makefile diff --git a/framework/hosting/simple_module_hosting/templates/host/README.md.tpl b/framework/cli/simple_module_cli/templates/host/README.md.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/README.md.tpl rename to framework/cli/simple_module_cli/templates/host/README.md.tpl diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet new file mode 100644 index 00000000..43e2827b --- /dev/null +++ b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet @@ -0,0 +1,12 @@ +# --- background_tasks ---------------------------------------------------- +.PHONY: worker beat worker-docker + +worker: ## Run a Celery worker locally against $(SM_BG_TASKS_BROKER_URL) + uv run celery -A scripts.run_worker:celery worker -l info + +beat: ## Run the Celery beat scheduler locally + uv run celery -A scripts.run_worker:celery beat -l info + +worker-docker: ## Build + run the worker + beat services in docker + docker compose up --build worker beat +# --- end background_tasks ------------------------------------------------ diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml new file mode 100644 index 00000000..46a1493f --- /dev/null +++ b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml @@ -0,0 +1,60 @@ +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + worker: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "worker" + - "-l" + - "info" + - "--concurrency=4" + + beat: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + worker: + condition: service_started + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "beat" + - "-l" + - "info" + +volumes: + redisdata: diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py new file mode 100644 index 00000000..82788b90 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py @@ -0,0 +1,17 @@ +"""Entry point for the Celery worker and beat services. + +Both the web process and the worker go through the same +``background_tasks.celery_app.build_celery`` factory so the broker +config, autodiscovered tasks, and signal handlers stay in lockstep. + +Run locally: + uv run celery -A scripts.run_worker:celery worker -l info + uv run celery -A scripts.run_worker:celery beat -l info +""" + +from __future__ import annotations + +from background_tasks.celery_app import build_celery +from background_tasks.settings import BackgroundTasksSettings + +celery = build_celery(BackgroundTasksSettings()) diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile new file mode 100644 index 00000000..120e96f3 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile @@ -0,0 +1,37 @@ +# Celery worker image for the BackgroundTasks module. +# Serves both the worker and beat services in docker-compose — they +# differ only by command. + +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_SYSTEM_PYTHON=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir uv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY scripts/ scripts/ +COPY client_app/ client_app/ + +RUN uv sync --frozen --no-dev + +RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin worker \ + && chown -R worker:worker /app +USER worker + +ENV CELERY_APP=scripts.run_worker:celery +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD uv run celery -A $CELERY_APP inspect ping -d celery@$HOSTNAME || exit 1 + +CMD ["uv", "run", "celery", "-A", "scripts.run_worker:celery", "worker", "-l", "info"] diff --git a/framework/hosting/simple_module_hosting/templates/host/alembic.ini b/framework/cli/simple_module_cli/templates/host/alembic.ini similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/alembic.ini rename to framework/cli/simple_module_cli/templates/host/alembic.ini diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/app.tsx b/framework/cli/simple_module_cli/templates/host/client_app/app.tsx similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/app.tsx rename to framework/cli/simple_module_cli/templates/host/client_app/app.tsx diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/main.tsx b/framework/cli/simple_module_cli/templates/host/client_app/main.tsx similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/main.tsx rename to framework/cli/simple_module_cli/templates/host/client_app/main.tsx diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/package.json.tpl b/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/package.json.tpl rename to framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/pages.ts b/framework/cli/simple_module_cli/templates/host/client_app/pages.ts similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/pages.ts rename to framework/cli/simple_module_cli/templates/host/client_app/pages.ts diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/pages/Error.tsx b/framework/cli/simple_module_cli/templates/host/client_app/pages/Error.tsx similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/pages/Error.tsx rename to framework/cli/simple_module_cli/templates/host/client_app/pages/Error.tsx diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/styles.css b/framework/cli/simple_module_cli/templates/host/client_app/styles.css similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/styles.css rename to framework/cli/simple_module_cli/templates/host/client_app/styles.css diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/tsconfig.json b/framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/tsconfig.json rename to framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/vite.config.ts rename to framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts diff --git a/framework/hosting/simple_module_hosting/templates/host/main.py b/framework/cli/simple_module_cli/templates/host/main.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/main.py rename to framework/cli/simple_module_cli/templates/host/main.py diff --git a/framework/hosting/simple_module_hosting/templates/host/migrations/env.py b/framework/cli/simple_module_cli/templates/host/migrations/env.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/migrations/env.py rename to framework/cli/simple_module_cli/templates/host/migrations/env.py diff --git a/framework/hosting/simple_module_hosting/templates/host/migrations/script.py.mako b/framework/cli/simple_module_cli/templates/host/migrations/script.py.mako similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/migrations/script.py.mako rename to framework/cli/simple_module_cli/templates/host/migrations/script.py.mako diff --git a/framework/hosting/simple_module_hosting/templates/host/migrations/versions/.gitkeep b/framework/cli/simple_module_cli/templates/host/migrations/versions/.gitkeep similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/migrations/versions/.gitkeep rename to framework/cli/simple_module_cli/templates/host/migrations/versions/.gitkeep diff --git a/framework/hosting/simple_module_hosting/templates/host/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/pyproject.toml.tpl rename to framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl diff --git a/framework/hosting/simple_module_hosting/templates/host/templates/index.html b/framework/cli/simple_module_cli/templates/host/templates/index.html similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/templates/index.html rename to framework/cli/simple_module_cli/templates/host/templates/index.html diff --git a/framework/hosting/simple_module_hosting/templates/module/.github/workflows/ci.yml b/framework/cli/simple_module_cli/templates/module/.github/workflows/ci.yml similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/.github/workflows/ci.yml rename to framework/cli/simple_module_cli/templates/module/.github/workflows/ci.yml diff --git a/framework/hosting/simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl b/framework/cli/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl rename to framework/cli/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/.gitignore b/framework/cli/simple_module_cli/templates/module/.gitignore similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/.gitignore rename to framework/cli/simple_module_cli/templates/module/.gitignore diff --git a/framework/hosting/simple_module_hosting/templates/module/README.md.tpl b/framework/cli/simple_module_cli/templates/module/README.md.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/README.md.tpl rename to framework/cli/simple_module_cli/templates/module/README.md.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/__init__.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/__init__.py diff --git a/framework/hosting/simple_module_hosting/templates/module/tests/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/tests/__init__.py rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/package.json.tpl b/framework/cli/simple_module_cli/templates/module/package.json.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/package.json.tpl rename to framework/cli/simple_module_cli/templates/module/package.json.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/pyproject.toml.tpl rename to framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl diff --git a/framework/cli/simple_module_cli/templates/module/tests/__init__.py b/framework/cli/simple_module_cli/templates/module/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/framework/hosting/simple_module_hosting/templates/module/tests/test_module.py.tpl b/framework/cli/simple_module_cli/templates/module/tests/test_module.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/tests/test_module.py.tpl rename to framework/cli/simple_module_cli/templates/module/tests/test_module.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/tsconfig.json.tpl b/framework/cli/simple_module_cli/templates/module/tsconfig.json.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/tsconfig.json.tpl rename to framework/cli/simple_module_cli/templates/module/tsconfig.json.tpl diff --git a/framework/cli/simple_module_cli/wizard.py b/framework/cli/simple_module_cli/wizard.py new file mode 100644 index 00000000..a83edc12 --- /dev/null +++ b/framework/cli/simple_module_cli/wizard.py @@ -0,0 +1,48 @@ +"""Interactive prompt sequence for ``sm new``.""" + +from __future__ import annotations + +import typer + +from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps + +__all__ = ["run_wizard"] + +_PRESET_CHOICES = ("minimal", "standard", "full", "custom") + + +def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: + db = typer.prompt("Database backend", default=default_db, type=str) + if db not in ("sqlite", "postgres"): + typer.echo(f"Invalid database: {db!r}; expected sqlite or postgres", err=True) + raise typer.Abort() + tenancy = typer.confirm("Enable multi-tenancy?", default=default_tenancy) + + typer.echo("\nPreset:") + typer.echo(" [1] minimal — users only") + typer.echo(" [2] standard — users, dashboard, permissions (default)") + typer.echo(" [3] full — every module") + typer.echo(" [4] custom — pick modules one by one") + choice = typer.prompt("Choose", default="2", type=str) + if choice not in {"1", "2", "3", "4"}: + typer.echo(f"Invalid choice: {choice!r}", err=True) + raise typer.Abort() + preset_name = _PRESET_CHOICES[int(choice) - 1] + + if preset_name == "custom": + picked = [ + name + for name in CATALOG + if typer.confirm(f"Include {CATALOG[name].display}?", default=False) + ] + else: + picked = list(PRESETS[preset_name]) + + resolved, added = expand_deps(picked) + for name, required_by in added: + typer.echo(f"Added {name} (required by {required_by})") + typer.echo(f"Selected modules: {', '.join(resolved)}") + + if not typer.confirm("Proceed?", default=True): + raise typer.Abort() + return db, tenancy, resolved diff --git a/framework/cli/tests/test_cli_catalog.py b/framework/cli/tests/test_cli_catalog.py new file mode 100644 index 00000000..dd8c0188 --- /dev/null +++ b/framework/cli/tests/test_cli_catalog.py @@ -0,0 +1,86 @@ +"""Tests for the module catalog and dependency expansion.""" + +from __future__ import annotations + +import pytest +from simple_module_cli.catalog import ( + CATALOG, + PRESETS, + ModuleEntry, + expand_deps, +) + + +def test_catalog_keys_match_entry_names() -> None: + for key, entry in CATALOG.items(): + assert key == entry.name, f"catalog key {key!r} != entry.name {entry.name!r}" + + +def test_every_requires_value_is_a_known_catalog_key() -> None: + for entry in CATALOG.values(): + for required in entry.requires: + assert required in CATALOG, f"{entry.name} requires unknown module {required!r}" + + +def test_presets_only_reference_known_modules() -> None: + for name, mods in PRESETS.items(): + for m in mods: + assert m in CATALOG, f"preset {name!r} references unknown module {m!r}" + + +def test_expand_deps_returns_input_when_no_requires() -> None: + resolved, added = expand_deps(["auth"]) + assert resolved == ["auth"] + assert added == [] + + +def test_expand_deps_pulls_in_transitive_dep() -> None: + resolved, added = expand_deps(["users"]) + assert set(resolved) == {"auth", "users"} + assert added == [("auth", "users")] + + +def test_expand_deps_pulls_in_chain() -> None: + resolved, added = expand_deps(["datasets"]) + assert set(resolved) == { + "datasets", + "file_storage", + "settings", + "background_tasks", + "users", + "auth", + } + added_names = {a for a, _ in added} + assert added_names == {"file_storage", "settings", "background_tasks", "users", "auth"} + + +def test_expand_deps_idempotent_when_input_already_complete() -> None: + resolved1, _ = expand_deps(["users"]) + resolved2, added2 = expand_deps(resolved1) + assert sorted(resolved1) == sorted(resolved2) + assert added2 == [] + + +def test_expand_deps_unknown_name_raises_with_available_list() -> None: + with pytest.raises(KeyError) as exc: + expand_deps(["does_not_exist"]) + msg = str(exc.value) + assert "does_not_exist" in msg + assert "auth" in msg + + +def test_expand_deps_preserves_load_order_dep_before_dependent() -> None: + resolved, _ = expand_deps(["dashboard"]) + for i, name in enumerate(resolved): + for required in CATALOG[name].requires: + assert resolved.index(required) < i, ( + f"{required} must appear before {name} in {resolved}" + ) + + +def test_module_entry_is_frozen() -> None: + from dataclasses import FrozenInstanceError + + entry = ModuleEntry(name="x", package="simple_module_x", display="X") + with pytest.raises(FrozenInstanceError): + entry.__setattr__("name", "y") diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py new file mode 100644 index 00000000..e82a966f --- /dev/null +++ b/framework/cli/tests/test_cli_new.py @@ -0,0 +1,210 @@ +"""Tests for the `sm new` / `simple-module new` CLI subcommand.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from simple_module_cli.cli import app +from typer.testing import CliRunner + + +def test_sm_new_creates_app_directory(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "my-app" + result = runner.invoke( + app, + ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert target.is_dir() + + +def test_sm_new_generates_pyproject_with_expected_deps(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "my-app" + runner.invoke( + app, + ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + pyproject_text = (target / "pyproject.toml").read_text() + for required in ( + "simple_module_hosting", + "simple_module_users", + "simple_module_dashboard", + "simple_module_permissions", + ): + assert required in pyproject_text, f"missing dep: {required}" + + +def test_sm_new_generates_package_json_with_npm_deps(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "my-app" + runner.invoke( + app, + ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + data = json.loads((target / "package.json").read_text()) + assert "@simple-module-py/ui" in data.get("dependencies", {}) + assert "@simple-module-py/i18n" in data.get("dependencies", {}) + assert "@simple-module-py/tsconfig" in data.get("devDependencies", {}) + + +def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "my-app" + runner.invoke( + app, + ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + env_text = (target / ".env.example").read_text() + assert "SM_SECRET_KEY=" in env_text + assert "CHANGE-ME" not in env_text + secret_line = next(ln for ln in env_text.splitlines() if ln.startswith("SM_SECRET_KEY=")) + assert len(secret_line.split("=", 1)[1]) >= 20 + + +def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: + from simple_module_cli.app_project import create_app_project + + target = tmp_path / "demo" + create_app_project( + target, + name="demo", + db="sqlite", + tenancy=False, + selected=["users", "background_tasks"], + ) + + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_background_tasks" in pyproject + assert "simple_module_auth" in pyproject # auto-added (users requires auth) + assert "simple_module_dashboard" not in pyproject + + +def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> None: + from simple_module_cli.app_project import create_app_project + + target = tmp_path / "demo" + create_app_project( + target, + name="demo", + db="sqlite", + tenancy=False, + selected=["background_tasks"], + ) + + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + assert (target / "docker" / "worker.Dockerfile").is_file() + makefile_text = (target / "Makefile").read_text() + assert "worker:" in makefile_text + + +def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) -> None: + from simple_module_cli.app_project import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False) + pyproject = (target / "pyproject.toml").read_text() + for required in ( + "simple_module_users", + "simple_module_dashboard", + "simple_module_permissions", + ): + assert required in pyproject + + +def test_sm_new_with_preset_full_includes_background_tasks(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + app, + ["new", "demo", "--yes", "--preset", "full", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_background_tasks" in pyproject + + +def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + app, + [ + "new", + "demo", + "--yes", + "--preset", + "minimal", + "--with", + "background_tasks", + "--no-install", + "--dest", + str(target), + ], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_users" in pyproject + assert "simple_module_background_tasks" in pyproject + assert "simple_module_auth" in pyproject + + +def test_sm_new_unknown_with_module_errors(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + app, + ["new", "demo", "--yes", "--with", "does_not_exist", "--no-install", "--dest", str(target)], + ) + assert result.exit_code != 0 + assert "does_not_exist" in result.output + assert "available" in result.output.lower() + + +def test_sm_new_yes_with_no_flags_uses_standard_preset(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + app, + ["new", "demo", "--yes", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + for required in ( + "simple_module_users", + "simple_module_dashboard", + "simple_module_permissions", + ): + assert required in pyproject + assert "simple_module_background_tasks" not in pyproject + + +def test_sm_new_interactive_full_preset(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + app, + ["new", "demo", "--no-install", "--dest", str(target)], + input="\n".join(["", "", "3", ""]) + "\n", + ) + assert result.exit_code == 0, result.output + assert (target / "docker-compose.yml").is_file() + + +def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None: + target = tmp_path / "my-app" + target.mkdir() + (target / "existing.txt").write_text("x") + + runner = CliRunner() + result = runner.invoke( + app, + ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + assert result.exit_code != 0 + assert "exists" in result.output.lower() or "exists" in (result.stderr or "").lower() diff --git a/framework/cli/tests/test_cli_recipes.py b/framework/cli/tests/test_cli_recipes.py new file mode 100644 index 00000000..bd6ab95d --- /dev/null +++ b/framework/cli/tests/test_cli_recipes.py @@ -0,0 +1,87 @@ +"""Tests for per-module post-scaffold recipes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from simple_module_cli.recipes import ( + RECIPES, + BackgroundTasksRecipe, + ScaffoldCtx, +) +from simple_module_cli.scaffolding import create_host + + +def _scaffold_minimal_host(target: Path) -> None: + create_host(target, name="demo", modules=["Users"]) + + +def _ctx() -> ScaffoldCtx: + return ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + + +def test_background_tasks_recipe_registered() -> None: + assert "background_tasks" in RECIPES + assert isinstance(RECIPES["background_tasks"], BackgroundTasksRecipe) + + +def test_recipe_writes_run_worker_script(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + script = tmp_path / "scripts" / "run_worker.py" + assert script.is_file() + text = script.read_text() + assert "from background_tasks.celery_app import build_celery" in text + assert "celery = build_celery(BackgroundTasksSettings())" in text + + +def test_recipe_writes_compose_with_redis_worker_beat(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + compose = (tmp_path / "docker-compose.yml").read_text() + assert "redis:" in compose + assert "worker:" in compose + assert "beat:" in compose + assert "scripts.run_worker:celery" in compose + + +def test_recipe_writes_worker_dockerfile(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + dockerfile = (tmp_path / "docker" / "worker.Dockerfile").read_text() + assert "FROM python:3.12-slim" in dockerfile + assert "scripts.run_worker:celery" in dockerfile + + +def test_recipe_appends_makefile_targets(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + makefile = (tmp_path / "Makefile").read_text() + assert "worker:" in makefile + assert "beat:" in makefile + assert "worker-docker:" in makefile + + +def test_recipe_sets_broker_url_env_var(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + env_text = (tmp_path / ".env.example").read_text() + assert "SM_BG_TASKS_BROKER_URL=redis://redis:6379/0" in env_text + + +def test_recipe_makefile_snippet_idempotent(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + first = (tmp_path / "Makefile").read_text() + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + assert (tmp_path / "Makefile").read_text() == first + + +def test_recipe_errors_on_existing_run_worker(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + (tmp_path / "scripts").mkdir(exist_ok=True) + (tmp_path / "scripts" / "run_worker.py").write_text("# user-authored\n") + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply(tmp_path, _ctx()) diff --git a/framework/cli/tests/test_cli_wizard.py b/framework/cli/tests/test_cli_wizard.py new file mode 100644 index 00000000..53cc6bcd --- /dev/null +++ b/framework/cli/tests/test_cli_wizard.py @@ -0,0 +1,78 @@ +"""Tests for the `sm new` interactive wizard.""" + +from __future__ import annotations + +import typer +from simple_module_cli.wizard import run_wizard +from typer.testing import CliRunner + + +def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: + """Run the wizard with stdin pre-fed; return (db, tenancy, selected, output).""" + captured: dict = {} + wrapper_app = typer.Typer() + + @wrapper_app.command() + def wrapper() -> None: + db, tenancy, selected = run_wizard(default_db="sqlite", default_tenancy=False) + captured["db"] = db + captured["tenancy"] = tenancy + captured["selected"] = selected + + runner = CliRunner() + result = runner.invoke(wrapper_app, [], input="\n".join(answers) + "\n") + assert result.exit_code == 0, result.output + return captured["db"], captured["tenancy"], captured["selected"], result.output + + +def test_wizard_standard_preset_default_path() -> None: + db, tenancy, selected, out = _drive(["", "", "", ""]) + assert db == "sqlite" + assert tenancy is False + assert "users" in selected and "dashboard" in selected and "permissions" in selected + assert "auth" in selected + assert "Added auth (required by" in out + + +def test_wizard_postgres_with_tenancy() -> None: + db, tenancy, _selected, _out = _drive(["postgres", "y", "", ""]) + assert db == "postgres" + assert tenancy is True + + +def test_wizard_minimal_preset() -> None: + _, _, selected, _ = _drive(["", "", "1", ""]) + assert set(selected) == {"users", "auth"} + + +def test_wizard_full_preset_includes_background_tasks() -> None: + _, _, selected, _ = _drive(["", "", "3", ""]) + assert "background_tasks" in selected + assert "datasets" in selected + assert len(selected) >= 10 + + +def test_wizard_custom_picks_only_yes_answers() -> None: + answers = ["", "", "4"] + ["n"] * 8 + ["y", "n", ""] + _, _, selected, out = _drive(answers) + assert set(selected) == {"background_tasks", "users", "auth"} + assert "Added users (required by background_tasks)" in out + assert "Added auth (required by users)" in out + + +def test_wizard_aborts_on_confirm_no() -> None: + captured: dict = {} + wrapper_app = typer.Typer() + + @wrapper_app.command() + def wrapper() -> None: + try: + run_wizard(default_db="sqlite", default_tenancy=False) + except typer.Abort: + captured["aborted"] = True + raise + + runner = CliRunner() + result = runner.invoke(wrapper_app, [], input="\n".join(["", "", "", "n"]) + "\n") + assert result.exit_code != 0 + assert captured.get("aborted") is True diff --git a/framework/cli/tests/test_no_framework_deps.py b/framework/cli/tests/test_no_framework_deps.py new file mode 100644 index 00000000..65ed57ef --- /dev/null +++ b/framework/cli/tests/test_no_framework_deps.py @@ -0,0 +1,26 @@ +"""Guard: `simple_module_cli` distribution depends only on typer + tomlkit. + +If a future change accidentally pulls in simple_module_core, FastAPI, +SQLModel, or anything else, this test fires immediately. +""" + +from __future__ import annotations + +from importlib.metadata import distribution + + +def _normalize(req: str) -> str: + """'typer (>=0.12)' -> 'typer'. Strip version specs + extras + spaces.""" + head = req.split(";", 1)[0] + for sep in ("(", ">=", ">", "<", "==", "[", " "): + head = head.split(sep, 1)[0] + return head.strip().lower().replace("_", "-") + + +def test_simple_module_cli_runtime_deps_are_minimal() -> None: + requires = distribution("simple_module_cli").requires or [] + names = {_normalize(r) for r in requires} + expected = {"typer", "tomlkit"} + assert names == expected, ( + f"simple_module_cli direct deps drifted; got {sorted(names)}, expected {sorted(expected)}" + ) diff --git a/framework/cli/tests/test_plugin_discovery.py b/framework/cli/tests/test_plugin_discovery.py new file mode 100644 index 00000000..05aaa69c --- /dev/null +++ b/framework/cli/tests/test_plugin_discovery.py @@ -0,0 +1,97 @@ +"""Tests for entry-point-based plugin discovery.""" + +from __future__ import annotations + +import textwrap +from importlib.metadata import EntryPoint + +import pytest +import typer +from simple_module_cli.plugins import discover_and_mount +from typer.testing import CliRunner + + +def _make_entry(name: str, module_attr: str) -> EntryPoint: + return EntryPoint(name=name, value=module_attr, group="simple_module_cli.cli_plugins") + + +@pytest.fixture +def fake_plugin_module(tmp_path, monkeypatch): + """Create a tiny package on sys.path that exports a Typer ``app``.""" + import sys + + pkg_dir = tmp_path / "fake_sm_plugin" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text( + textwrap.dedent( + """ + import typer + app = typer.Typer(help="Fake plugin.") + + @app.command("ping") + def ping(): + typer.echo("pong-from-fake") + """ + ) + ) + monkeypatch.syspath_prepend(str(tmp_path)) + yield "fake_sm_plugin:app" + sys.modules.pop("fake_sm_plugin", None) + + +def test_discover_mounts_valid_plugin(monkeypatch, fake_plugin_module) -> None: + monkeypatch.setattr( + "simple_module_cli.plugins._iter_plugin_entries", + lambda: [_make_entry("fake", fake_plugin_module)], + ) + root = typer.Typer() + discover_and_mount(root) + + runner = CliRunner() + result = runner.invoke(root, ["fake", "ping"]) + assert result.exit_code == 0, result.output + assert "pong-from-fake" in result.output + + +def _root_with_builtin() -> typer.Typer: + """Typer requires at least one command before --help works.""" + root = typer.Typer() + + @root.command("noop") + def _noop() -> None: + pass + + return root + + +def test_discover_skips_broken_plugin(monkeypatch, capsys) -> None: + bad = _make_entry("broken", "nonexistent_module:app") + monkeypatch.setattr("simple_module_cli.plugins._iter_plugin_entries", lambda: [bad]) + root = _root_with_builtin() + discover_and_mount(root) # should not raise + + captured = capsys.readouterr() + assert "failed to load plugin 'broken'" in captured.err + + runner = CliRunner() + result = runner.invoke(root, ["broken"]) + assert result.exit_code != 0 + + +def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, capsys) -> None: + a = _make_entry("dup", fake_plugin_module) + b = _make_entry("dup", fake_plugin_module) + monkeypatch.setattr("simple_module_cli.plugins._iter_plugin_entries", lambda: [a, b]) + root = typer.Typer() + discover_and_mount(root) + captured = capsys.readouterr() + assert "duplicate" in captured.err.lower() or "already" in captured.err.lower() + + +def test_discover_with_no_plugins_is_noop(monkeypatch) -> None: + monkeypatch.setattr("simple_module_cli.plugins._iter_plugin_entries", list) + root = _root_with_builtin() + discover_and_mount(root) + runner = CliRunner() + result = runner.invoke(root, ["--help"]) + assert result.exit_code == 0 diff --git a/framework/hosting/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py similarity index 89% rename from framework/hosting/tests/test_scaffolding_host.py rename to framework/cli/tests/test_scaffolding_host.py index 0e18d920..c256f70e 100644 --- a/framework/hosting/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -11,7 +11,7 @@ class TestModulePagesManifest: async def test_compute_returns_existing_page_dirs(self): """Returns {ModuleName: Path} for installed modules that ship a pages/ dir.""" from simple_module_core import discover_modules - from simple_module_hosting.scaffolding import compute_module_pages + from simple_module_hosting.manifest import compute_module_pages modules = discover_modules() result = compute_module_pages(modules) @@ -26,7 +26,7 @@ async def test_compute_returns_existing_page_dirs(self): async def test_compute_skips_modules_without_pages_dir(self, tmp_path, monkeypatch): """A module whose package has no pages/ dir is omitted (not an error).""" from simple_module_core import ModuleBase, ModuleMeta - from simple_module_hosting.scaffolding import compute_module_pages + from simple_module_hosting.manifest import compute_module_pages class HeadlessMod(ModuleBase): meta = ModuleMeta(name="Headless") @@ -39,7 +39,7 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): import json from simple_module_core import discover_modules - from simple_module_hosting.scaffolding import write_module_pages_manifest + from simple_module_hosting.manifest import write_module_pages_manifest modules = discover_modules() written = write_module_pages_manifest(modules, tmp_path) @@ -75,7 +75,7 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): class TestCreateHost: async def test_creates_expected_backend_files(self, tmp_path): """create_host writes the full backend + frontend scaffold.""" - from simple_module_hosting.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo-host", modules=["Products", "Auth"]) @@ -105,7 +105,7 @@ async def test_creates_expected_backend_files(self, tmp_path): async def test_package_json_carries_host_name(self, tmp_path): """client_app/package.json has its `name` prefixed with the host name.""" - from simple_module_hosting.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="my-host", modules=[]) @@ -114,7 +114,7 @@ async def test_package_json_carries_host_name(self, tmp_path): async def test_substitutes_host_name_into_pyproject(self, tmp_path): """The host name lands in pyproject.toml's [project].name field.""" - from simple_module_hosting.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="my-acme-app", modules=[]) @@ -123,7 +123,7 @@ async def test_substitutes_host_name_into_pyproject(self, tmp_path): async def test_declares_selected_module_deps(self, tmp_path): """Each module from --with appears as a PyPI dep in pyproject.toml.""" - from simple_module_hosting.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo", modules=["Products", "Auth"]) @@ -133,7 +133,7 @@ async def test_declares_selected_module_deps(self, tmp_path): async def test_refuses_existing_non_empty_dir(self, tmp_path): """create_host aborts if the destination exists and is non-empty — no clobbering.""" - from simple_module_hosting.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "existing" dest.mkdir() @@ -144,7 +144,7 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): async def test_env_py_uses_shared_helper(self, tmp_path): """Scaffolded migrations/env.py delegates to the shared helper, not inline logic.""" - from simple_module_hosting.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo", modules=[]) @@ -155,12 +155,12 @@ async def test_env_py_uses_shared_helper(self, tmp_path): async def test_cli_create_host_runs_end_to_end(self, tmp_path): """The Click `sm create-host` command produces a working scaffold.""" - from click.testing import CliRunner - from simple_module_hosting.cli import main + from simple_module_cli.cli import app + from typer.testing import CliRunner runner = CliRunner() result = runner.invoke( - main, + app, ["create-host", "smoke-host", "--dest", str(tmp_path / "out"), "--with", "Products"], ) assert result.exit_code == 0, result.output diff --git a/framework/hosting/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py similarity index 87% rename from framework/hosting/tests/test_scaffolding_module.py rename to framework/cli/tests/test_scaffolding_module.py index 852809d9..ece250ff 100644 --- a/framework/hosting/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -8,7 +8,7 @@ class TestCreateModule: async def test_creates_expected_module_files(self, tmp_path): """create_module writes a PyPI-ready module package.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -28,7 +28,7 @@ async def test_creates_expected_module_files(self, tmp_path): async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): """pyproject.toml sets the entry_point and pins the framework API range.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -41,7 +41,7 @@ async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): async def test_module_py_subclasses_module_base(self, tmp_path): """The generated module.py has a ModuleBase subclass with the right Meta.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -53,7 +53,7 @@ async def test_module_py_subclasses_module_base(self, tmp_path): async def test_snake_case_derivation(self, tmp_path): """Module names with dashes, spaces, or camel case convert to snake_case packages.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-order-tracker" create_module(dest, name="OrderTracker") @@ -61,7 +61,7 @@ async def test_snake_case_derivation(self, tmp_path): async def test_refuses_existing_non_empty_dir(self, tmp_path): """create_module aborts rather than clobber an existing directory.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "existing" dest.mkdir() @@ -71,13 +71,13 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): async def test_cli_create_module_runs_end_to_end(self, tmp_path): """The Click `sm create-module` command produces a working scaffold.""" - from click.testing import CliRunner - from simple_module_hosting.cli import main + from simple_module_cli.cli import app + from typer.testing import CliRunner runner = CliRunner() dest = tmp_path / "simple-module-smoke" result = runner.invoke( - main, + app, ["create-module", "Smoke", "--dest", str(dest)], ) assert result.exit_code == 0, result.output @@ -88,7 +88,7 @@ async def test_cli_create_module_runs_end_to_end(self, tmp_path): async def test_scaffold_ships_github_workflows(self, tmp_path): """Gap 8: scaffolded modules include publish.yml + ci.yml.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -100,7 +100,7 @@ async def test_scaffold_ships_github_workflows(self, tmp_path): async def test_publish_workflow_uses_trusted_publishing(self, tmp_path): """publish.yml must request OIDC token and use pypa/gh-action-pypi-publish.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -114,7 +114,7 @@ async def test_publish_workflow_uses_trusted_publishing(self, tmp_path): async def test_publish_workflow_triggers_on_version_tag(self, tmp_path): """publish.yml fires only on tag push, not every commit to main.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -124,7 +124,7 @@ async def test_publish_workflow_triggers_on_version_tag(self, tmp_path): async def test_workflows_parse_as_valid_yaml(self, tmp_path): """Both workflow files must be parseable YAML — catches template substitution bugs.""" import yaml - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -137,7 +137,7 @@ async def test_workflows_parse_as_valid_yaml(self, tmp_path): async def test_scaffold_has_pages_dir(self, tmp_path): """Gap 2b: modules intended to ship TSX pages get a pages/ dir from day one.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -147,7 +147,7 @@ async def test_scaffold_has_pages_dir(self, tmp_path): async def test_pyproject_force_includes_static_dist(self, tmp_path): """Gap 2b: pyproject.toml must ship /static/dist/ inside the wheel.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -160,7 +160,7 @@ async def test_pyproject_force_includes_static_dist(self, tmp_path): async def test_module_py_mounts_static_dist_conditionally(self, tmp_path): """Generated module.py exposes static_mounts() that tolerates a missing dist/.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -171,7 +171,7 @@ async def test_module_py_mounts_static_dist_conditionally(self, tmp_path): async def test_gitignore_excludes_built_assets(self, tmp_path): """Built JS lives in source control's blind spot; only wheels carry it.""" - from simple_module_hosting.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") diff --git a/framework/core/simple_module_core/diagnostics/_coupling.py b/framework/core/simple_module_core/diagnostics/_coupling.py index 0119b519..4bb67163 100644 --- a/framework/core/simple_module_core/diagnostics/_coupling.py +++ b/framework/core/simple_module_core/diagnostics/_coupling.py @@ -54,6 +54,11 @@ def check_framework_module_coupling(modules: list[ModuleBase]) -> list[Diagnosti diags: list[Diagnostic] = [] for fw_pkg, fw_dir in framework_dirs: for py_file in fw_dir.rglob("*.py"): + # `templates/` ships as package data — its `.py` files are not + # framework code that runs at import time. They land in the + # scaffolded user project and import plugins legitimately there. + if "templates" in py_file.relative_to(fw_dir).parts: + continue try: tree = ast.parse(py_file.read_text(), filename=str(py_file)) except SyntaxError: diff --git a/framework/hosting/README.md b/framework/hosting/README.md index 0d5fea0f..283ae541 100644 --- a/framework/hosting/README.md +++ b/framework/hosting/README.md @@ -1,6 +1,6 @@ # simple_module_hosting -FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/antosubash/simple_module_python) framework — builds the app, wires the middleware pipeline, exposes the `sm` / `simple-module` CLI, and ships the project scaffolder. +FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/antosubash/simple_module_python) framework — builds the app, wires the middleware pipeline, and contributes the `sm host` plugin to the standalone `sm` CLI. ## Install @@ -8,10 +8,10 @@ FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/ant pip install simple_module_hosting ``` -For a new project, most users run the generator instead: +For a new project, most users run the generator instead (shipped by the standalone `simple_module_cli` distribution): ```bash -uvx simple-module new my-app +uvx --from simple_module_cli sm new my-app ``` ## What it provides @@ -19,8 +19,7 @@ uvx simple-module new my-app - `create_app(settings)` — returns a fully-wired `FastAPI` instance with all discovered modules registered. - Middleware pipeline (execution order): CorrelationId → RequestLogging → SecurityHeaders → Session → `` → Tenant (opt-in) → Locale → InertiaLayoutData → app. - Inertia wiring — shared props (`auth`, `menus`, `i18n`), `InertiaDep`, page-route lookup. -- CLI entry points: both `sm` and `simple-module` are installed and alias the same Click tree. -- Scaffolders — `sm create-host`, `sm create-module`, `sm new` (greenfield app with users + dashboard + permissions pre-wired), `sm gen-pages`. +- `sm host` plugin — `sm host gen-pages` regenerates the frontend pages manifest; `sm host sync-js-deps` installs JS deps declared by installed modules. The `sm` binary itself comes from `simple_module_cli`. ## Usage @@ -38,16 +37,13 @@ if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) ``` -CLI: +CLI (after also installing `simple_module_cli`): ```bash -simple-module new my-app # scaffold a new project -simple-module doctor # diagnostic codes (SM001-SM017) -simple-module gen-pages # regenerate client_app/modules.generated.ts +sm host gen-pages # regenerate client_app/modules.generated.ts +sm host sync-js-deps # sync module JS deps into client_app/node_modules ``` -`sm` works identically to `simple-module`. - ## Depends on - `simple_module_core`, `simple_module_db` diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index 636659fe..b8513000 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -33,9 +33,8 @@ dependencies = [ "uvicorn[standard]>=0.34", ] -[project.scripts] -sm = "simple_module_hosting.cli:main" -simple-module = "simple_module_hosting.cli:main" +[project.entry-points."simple_module_cli.cli_plugins"] +host = "simple_module_hosting.host_cli:app" [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 080ab136..7a006fed 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -131,7 +131,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: # Emit frontend module-pages manifest so Vite can find pages shipped # inside pip-installed module wheels. See scaffolding.py. try: - from simple_module_hosting.scaffolding import write_module_pages_manifest + from simple_module_hosting.manifest import write_module_pages_manifest client_app = _PROJECT_ROOT / "host" / "client_app" if client_app.is_dir(): diff --git a/framework/hosting/simple_module_hosting/cli.py b/framework/hosting/simple_module_hosting/cli.py deleted file mode 100644 index bd11abda..00000000 --- a/framework/hosting/simple_module_hosting/cli.py +++ /dev/null @@ -1,292 +0,0 @@ -"""SimpleModule CLI — `sm` console script. - -Currently exposes: - -* ``sm create-host `` — scaffold a new host directory. -* ``sm create-module `` — scaffold a new module package. -* ``sm gen-pages`` — regenerate the frontend pages manifest + Tailwind CSS. -* ``sm sync-js-deps`` — install JS deps declared by installed modules. -""" - -from __future__ import annotations - -import logging -import shutil -import subprocess -import sys -from pathlib import Path - -import click -from simple_module_core import discover_modules - -from simple_module_hosting.scaffolding import ( - _to_kebab_case, - collect_module_js_deps, - create_module, - repo_root_from_client_app, - write_module_pages_manifest, -) -from simple_module_hosting.scaffolding import ( - create_host as _create_host, -) - - -@click.group() -def main() -> None: - """SimpleModule developer CLI.""" - - -@main.command("new") -@click.argument("name") -@click.option( - "--dest", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help="Destination directory. Defaults to ./.", -) -@click.option( - "--db", - type=click.Choice(["sqlite", "postgres"]), - default="sqlite", - show_default=True, - help="Database backend to configure in .env.example.", -) -@click.option( - "--tenancy/--no-tenancy", - default=False, - show_default=True, - help="Enable the multi-tenant middleware by default.", -) -@click.option( - "--yes", - "-y", - is_flag=True, - default=False, - help="Skip interactive prompts; accept all defaults.", -) -@click.option( - "--no-install", - is_flag=True, - default=False, - help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", -) -def new_project( - name: str, - dest: Path | None, - db: str, - tenancy: bool, - yes: bool, - no_install: bool, -) -> None: - """Scaffold a new SimpleModule app — pre-wired with users, dashboard, permissions.""" - target = dest or Path.cwd() / name - if not yes: - db = click.prompt( - "Database backend", - default=db, - type=click.Choice(["sqlite", "postgres"]), - ) - tenancy = click.confirm("Enable multi-tenancy?", default=tenancy) - - from simple_module_hosting.scaffolding import create_app_project - - try: - create_app_project(target, name=name, db=db, tenancy=tenancy) - except FileExistsError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) - - click.echo(f"Created app '{name}' at {target}") - click.echo("\nPre-wired modules: users, dashboard, permissions") - click.echo("\nNext steps:") - click.echo(f" cd {target}") - if no_install: - click.echo(" uv sync") - click.echo(" npm install") - click.echo(" alembic upgrade head") - click.echo(" make dev") - return - - click.echo("Installing dependencies...") - for cmd in (["uv", "sync"], ["npm", "install"]): - result = subprocess.run(cmd, cwd=target, check=False) - if result.returncode != 0: - click.echo( - f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " - f"finish setup manually.", - err=True, - ) - return - - subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) - click.echo("\nSetup complete. Run `make dev` in the new directory.") - - -@main.command("create-host") -@click.argument("name") -@click.option( - "--dest", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help="Destination directory. Defaults to ./.", -) -@click.option( - "--with", - "modules", - default="", - help="Comma-separated module names to declare as deps (e.g. --with=Auth,Products).", -) -def create_host(name: str, dest: Path | None, modules: str) -> None: - """Scaffold a new SimpleModule host project at ./.""" - target = dest or Path.cwd() / name - selected = [m.strip() for m in modules.split(",") if m.strip()] - - try: - _create_host(target, name=name, modules=selected) - except FileExistsError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) - - click.echo(f"Created host '{name}' at {target}") - if selected: - click.echo(f"Declared modules: {', '.join(selected)}") - click.echo("\nNext steps:") - click.echo(f" cd {target}") - click.echo(" uv sync") - click.echo(" cp .env.example .env") - click.echo(' alembic revision --autogenerate -m "initial schema"') - click.echo(" alembic upgrade head") - click.echo(" python main.py") - - -@main.command("create-module") -@click.argument("name") -@click.option( - "--dest", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help="Destination directory. Defaults to ./simple_module_.", -) -def create_module_cmd(name: str, dest: Path | None) -> None: - """Scaffold a publishable SimpleModule module package.""" - slug = _to_kebab_case(name) - package = slug.replace("-", "_") - target = dest or Path.cwd() / f"simple_module_{package}" - - try: - create_module(target, name=name) - except FileExistsError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) - - click.echo(f"Created module 'simple_module_{package}' at {target}") - click.echo("\nNext steps:") - click.echo(f" cd {target}") - click.echo(" uv sync --extra dev") - click.echo(" uv run pytest") - - -@main.command("gen-pages") -@click.option( - "--host-dir", - type=click.Path(file_okay=False, exists=True, path_type=Path), - default=None, - help="Path to the host's client_app directory. Defaults to ./client_app.", -) -def gen_pages(host_dir: Path | None) -> None: - """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - output = host_dir or Path.cwd() / "client_app" - if not output.is_dir(): - click.echo(f"ERROR: client_app directory not found at {output}", err=True) - sys.exit(1) - - modules = discover_modules() - written = write_module_pages_manifest(modules, output) - click.echo( - f"Wrote {written['manifest'].name}, {written['generated'].name}, " - f"{written['css'].name} to {output}" - ) - - -@main.command("sync-js-deps") -@click.option( - "--host-client-app", - type=click.Path(file_okay=False, exists=True, path_type=Path), - default=None, - help="Path to host/client_app. Defaults to ./client_app.", -) -@click.option( - "--dry-run", - is_flag=True, - default=False, - help="Print the npm install command without running it.", -) -def sync_js_deps(host_client_app: Path | None, dry_run: bool) -> None: - """Install JS deps declared by installed modules into host's node_modules. - - Walks every discovered module, reads its package.json, and runs a single - ``npm install --workspace host/client_app --save=false ``. Use - this after ``pip install``-ing a module wheel that declares JS deps; - in-repo modules already flow through npm workspaces and need nothing. - """ - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - - output = host_client_app or Path.cwd() / "client_app" - if not output.is_dir(): - click.echo(f"ERROR: client_app directory not found at {output}", err=True) - sys.exit(1) - - modules = discover_modules() - by_module = collect_module_js_deps(modules) - if not by_module: - click.echo("No module JS dependencies declared.") - return - - # Flatten into a single spec list. npm's own resolver handles conflicts. - specs: list[str] = [] - for mod_name in sorted(by_module): - for dep, rng in sorted(by_module[mod_name].items()): - specs.append(f"{dep}@{rng}") - # Dedupe while preserving first-seen order. - deduped: list[str] = [] - seen: set[str] = set() - for spec in specs: - if spec not in seen: - seen.add(spec) - deduped.append(spec) - - npm = shutil.which("npm") - if npm is None: - click.echo("ERROR: npm not found on PATH.", err=True) - sys.exit(1) - - # Workspace path is relative to the repo root — derive it from output. - repo_root = repo_root_from_client_app(output) - try: - workspace = str(output.resolve().relative_to(repo_root)) - except ValueError: - workspace = str(output.resolve()) - - cmd = [ - npm, - "install", - "--workspace", - workspace, - "--save=false", - "--no-audit", - "--no-fund", - *deduped, - ] - click.echo("Installing module JS deps:") - for spec in deduped: - click.echo(f" {spec}") - if dry_run: - click.echo("(dry-run) " + " ".join(cmd)) - return - result = subprocess.run(cmd, cwd=repo_root, check=False) - sys.exit(result.returncode) - - -if __name__ == "__main__": - main() diff --git a/framework/hosting/simple_module_hosting/host_cli.py b/framework/hosting/simple_module_hosting/host_cli.py new file mode 100644 index 00000000..a386cb56 --- /dev/null +++ b/framework/hosting/simple_module_hosting/host_cli.py @@ -0,0 +1,117 @@ +"""``sm host`` plugin — project-time helpers exposed through the simple-module CLI. + +Commands here need module discovery (``simple_module_core.discover_modules``) +and the manifest helpers; they're not part of the standalone scaffolder. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from pathlib import Path +from typing import Annotated + +import typer +from simple_module_core import discover_modules + +from simple_module_hosting.manifest import ( + collect_module_js_deps, + repo_root_from_client_app, + write_module_pages_manifest, +) + +app = typer.Typer( + help="Project-time helpers (frontend pages manifest, module JS dep sync).", + no_args_is_help=True, +) + + +@app.command("gen-pages") +def gen_pages( + host_dir: Annotated[ + Path, + typer.Option( + "--host-dir", + help="Path to the host's client_app directory. Defaults to ./client_app.", + ), + ] = Path("client_app"), +) -> None: + """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_dir.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_dir}", err=True) + raise typer.Exit(code=1) + modules = discover_modules() + written = write_module_pages_manifest(modules, host_dir) + typer.echo( + f"Wrote {written['manifest'].name}, {written['generated'].name}, " + f"{written['css'].name} to {host_dir}" + ) + + +@app.command("sync-js-deps") +def sync_js_deps( + host_client_app: Annotated[ + Path, + typer.Option( + "--host-client-app", + help="Path to host/client_app. Defaults to ./client_app.", + ), + ] = Path("client_app"), + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Print the npm install command only.") + ] = False, +) -> None: + """Install JS deps declared by installed modules into host's node_modules.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_client_app.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_client_app}", err=True) + raise typer.Exit(code=1) + + modules = discover_modules() + by_module = collect_module_js_deps(modules) + if not by_module: + typer.echo("No module JS dependencies declared.") + return + + specs: list[str] = [] + for mod_name in sorted(by_module): + for dep, rng in sorted(by_module[mod_name].items()): + specs.append(f"{dep}@{rng}") + deduped: list[str] = [] + seen: set[str] = set() + for spec in specs: + if spec not in seen: + seen.add(spec) + deduped.append(spec) + + npm = shutil.which("npm") + if npm is None: + typer.echo("ERROR: npm not found on PATH.", err=True) + raise typer.Exit(code=1) + + repo_root = repo_root_from_client_app(host_client_app) + try: + workspace = str(host_client_app.resolve().relative_to(repo_root)) + except ValueError: + workspace = str(host_client_app.resolve()) + + cmd = [ + npm, + "install", + "--workspace", + workspace, + "--save=false", + "--no-audit", + "--no-fund", + *deduped, + ] + typer.echo("Installing module JS deps:") + for spec in deduped: + typer.echo(f" {spec}") + if dry_run: + typer.echo("(dry-run) " + " ".join(cmd)) + return + result = subprocess.run(cmd, cwd=repo_root, check=False) + raise typer.Exit(code=result.returncode) diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py deleted file mode 100644 index a5892ebf..00000000 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Host + module scaffolding via package-data templates. - -* :func:`create_host` materializes a new host project from the templates - under ``simple_module_hosting/templates/host/``. -* :func:`create_module` materializes a new module package from - ``simple_module_hosting/templates/module/``. - -The frontend pages manifest + per-module JS dep discovery used to live -here as well; both moved to :mod:`simple_module_hosting.manifest` to -keep this file under the project's per-file line cap. They're re-exported -below so existing import sites keep working. -""" - -from __future__ import annotations - -import importlib.resources -import json as _json -import logging -import re -import secrets as _secrets -import shutil -from collections.abc import Mapping, Sequence -from pathlib import Path - -from simple_module_hosting.manifest import ( - collect_module_js_deps, - compute_module_pages, - read_module_package_json, - repo_root_from_client_app, - write_module_pages_manifest, -) - -__all__ = [ - "collect_module_js_deps", - "compute_module_pages", - "create_app_project", - "create_host", - "create_module", - "read_module_package_json", - "repo_root_from_client_app", - "write_module_pages_manifest", -] - -logger = logging.getLogger(__name__) - -# Templates ship as package data under simple_module_hosting/templates/{host,module}/. -_TEMPLATES_PACKAGE = "simple_module_hosting.templates" - -# Path-segment substitution token used by create_module. -_PACKAGE_PATH_TOKEN = "__PACKAGE__" - - -def _module_to_pypi_name(name: str) -> str: - """'Products' -> 'simple_module_products'. Matches the publishing convention.""" - return f"simple_module_{name.lower()}" - - -def _iter_template_files(template_root: Path): - """Yield every file under ``template_root``, preserving relative paths.""" - for path in template_root.rglob("*"): - if path.is_file(): - yield path - - -def _require_empty_dest(dest: Path) -> None: - """Raise if ``dest`` is an existing non-empty directory — never clobber files.""" - if dest.exists() and any(dest.iterdir()): - raise FileExistsError( - f"Destination {dest} already exists and is non-empty. " - "Choose a new path or remove the contents first." - ) - dest.mkdir(parents=True, exist_ok=True) - - -def _resolve_template_root(subdir: str, override: Path | None) -> Path: - """Return the scaffold template root, either from package data or an override.""" - if override is not None: - return Path(override) - return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir)) - - -def _apply_template_files( - src_root: Path, - dest: Path, - substitutions: Mapping[str, str], - *, - path_rewrites: Mapping[str, str] | None = None, -) -> None: - """Copy every file under ``src_root`` to ``dest``, applying substitutions. - - Files ending in ``.tpl`` are read as text, placeholders replaced, and - written without the suffix. Every other file is copied verbatim. If - ``path_rewrites`` is given, each key is replaced by its value anywhere - in relative paths (used by :func:`create_module` to rename the - ``__PACKAGE__`` directory placeholder). - """ - for src in _iter_template_files(src_root): - rel_str = str(src.relative_to(src_root)) - for old, new in (path_rewrites or {}).items(): - rel_str = rel_str.replace(old, new) - rel_str = rel_str.removesuffix(".tpl") - target = dest / rel_str - target.parent.mkdir(parents=True, exist_ok=True) - - if src.suffix == ".tpl": - text = src.read_text(encoding="utf-8") - for placeholder, value in substitutions.items(): - text = text.replace(placeholder, value) - target.write_text(text, encoding="utf-8") - else: - shutil.copy2(src, target) - - -def create_host( - dest: Path, - name: str, - modules: Sequence[str], - template_root: Path | None = None, -) -> Path: - """Materialize a SimpleModule host scaffold at ``dest``. - - Modules listed in ``modules`` become PyPI dependencies in the scaffolded - ``pyproject.toml`` (e.g. ``"simple_module_products>=0.1,<1.0"``). Raises - :class:`FileExistsError` if ``dest`` is an existing non-empty directory. - """ - dest = Path(dest) - _require_empty_dest(dest) - - module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules) - _apply_template_files( - _resolve_template_root("host", template_root), - dest, - {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines}, - ) - - logger.info( - "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" - ) - return dest - - -def _to_snake_case(name: str) -> str: - """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'.""" - s = re.sub(r"(? str: - """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug).""" - return _to_snake_case(name).replace("_", "-") - - -def _to_pascal_case(name: str) -> str: - """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta).""" - snake = _to_snake_case(name) - return "".join(part.capitalize() for part in snake.split("_") if part) - - -def create_module( - dest: Path, - name: str, - template_root: Path | None = None, -) -> Path: - """Materialize a publishable module package at ``dest``. - - ``name`` is accepted in any case style (``MyFeature``, ``my-feature``, - ``my_feature``) and normalized to three forms: - - * ``MODULE_NAME`` — ``PascalCase``, appears in ``Meta(name=...)`` - * ``MODULE_SLUG`` — ``kebab-case``, used in the PyPI distribution name - * ``PACKAGE_NAME`` — ``snake_case``, the importable Python package and - the entry_point key - """ - dest = Path(dest) - _require_empty_dest(dest) - - display_name = _to_pascal_case(name) - slug = _to_kebab_case(name) - package_name = _to_snake_case(name) - - _apply_template_files( - _resolve_template_root("module", template_root), - dest, - substitutions={ - "{{MODULE_NAME}}": display_name, - "{{MODULE_SLUG}}": slug, - "{{PACKAGE_NAME}}": package_name, - }, - path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, - ) - - logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name) - return dest - - -# --------------------------------------------------------------- -# create_app_project — used by `sm new` / `simple-module new` -# --------------------------------------------------------------- - -_FRAMEWORK_VERSION = "0.0.1" - -_APP_PY_DEPS = [ - f"simple_module_hosting=={_FRAMEWORK_VERSION}", - f"simple_module_users=={_FRAMEWORK_VERSION}", - f"simple_module_dashboard=={_FRAMEWORK_VERSION}", - f"simple_module_permissions=={_FRAMEWORK_VERSION}", -] -_APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"] - -_APP_NPM_DEPS = { - "@simple-module-py/ui": _FRAMEWORK_VERSION, - "@simple-module-py/i18n": _FRAMEWORK_VERSION, - "react": "^19.0.0", - "react-dom": "^19.0.0", - "@inertiajs/react": "^1.0.0", -} -_APP_NPM_DEV_DEPS = { - "@simple-module-py/tsconfig": _FRAMEWORK_VERSION, - "@vitejs/plugin-react": "^5.0.0", - "typescript": "^5.6.0", - "vite": "^8.0.0", -} - - -def create_app_project( - target: Path, - *, - name: str, - db: str = "sqlite", - tenancy: bool = False, -) -> None: - """Greenfield ``simple-module new`` scaffold. - - Wraps :func:`create_host` with opinionated pre-wired modules (users + - dashboard + permissions), generates a secret, picks a DB URL, and rewrites - the generated package.json / pyproject.toml to pin exact framework - versions. - """ - if target.exists() and any(target.iterdir()): - raise FileExistsError( - f"Destination {target} already exists and is non-empty; " - "choose a new path or remove its contents first." - ) - - create_host(target, name=name, modules=["users", "dashboard", "permissions"]) - - 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)) - env_text = _set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, _to_kebab_case(name))) - env_text = _set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false") - env_path.write_text(env_text, encoding="utf-8") - - pyproject = target / "pyproject.toml" - if pyproject.exists(): - text = pyproject.read_text(encoding="utf-8") - text = _inject_py_deps(text, _APP_PY_DEPS, _APP_PY_DEV_DEPS) - pyproject.write_text(text, encoding="utf-8") - - pkg_path = target / "package.json" - if pkg_path.exists(): - data = _json.loads(pkg_path.read_text(encoding="utf-8")) - else: - data = {"name": _to_kebab_case(name), "private": True, "type": "module"} - data.setdefault("dependencies", {}).update(_APP_NPM_DEPS) - data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) - pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") - - -def _set_env_key(text: str, key: str, value: str) -> str: - lines = text.splitlines() - prefix = f"{key}=" - out = [ln for ln in lines if not ln.startswith(prefix)] - out.append(f"{key}={value}") - return "\n".join(out) + "\n" - - -def _db_url(db: str, slug: str) -> str: - if db == "postgres": - return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" - return "sqlite+aiosqlite:///./app.db" - - -def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str: - """Replace project.dependencies + dependency-groups.dev in a pyproject.toml.""" - import tomlkit - - doc = tomlkit.parse(text) - project = doc.setdefault("project", tomlkit.table()) - project["dependencies"] = list(deps) - groups = doc.setdefault("dependency-groups", tomlkit.table()) - groups["dev"] = list(dev_deps) - return tomlkit.dumps(doc) diff --git a/framework/hosting/tests/test_cli_new.py b/framework/hosting/tests/test_cli_new.py deleted file mode 100644 index 7276d84b..00000000 --- a/framework/hosting/tests/test_cli_new.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Tests for the `sm new` / `simple-module new` CLI subcommand.""" - -from __future__ import annotations - -import json -from pathlib import Path - -from click.testing import CliRunner -from simple_module_hosting.cli import main - - -def test_sm_new_creates_app_directory(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "my-app" - result = runner.invoke( - main, - ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - assert result.exit_code == 0, result.output - assert target.is_dir() - - -def test_sm_new_generates_pyproject_with_expected_deps(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "my-app" - runner.invoke( - main, - ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - pyproject_text = (target / "pyproject.toml").read_text() - for required in ( - "simple_module_hosting", - "simple_module_users", - "simple_module_dashboard", - "simple_module_permissions", - ): - assert required in pyproject_text, f"missing dep: {required}" - - -def test_sm_new_generates_package_json_with_npm_deps(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "my-app" - runner.invoke( - main, - ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - data = json.loads((target / "package.json").read_text()) - assert "@simple-module-py/ui" in data.get("dependencies", {}) - assert "@simple-module-py/i18n" in data.get("dependencies", {}) - assert "@simple-module-py/tsconfig" in data.get("devDependencies", {}) - - -def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "my-app" - runner.invoke( - main, - ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - env_text = (target / ".env.example").read_text() - assert "SM_SECRET_KEY=" in env_text - assert "CHANGE-ME" not in env_text - secret_line = next(ln for ln in env_text.splitlines() if ln.startswith("SM_SECRET_KEY=")) - assert len(secret_line.split("=", 1)[1]) >= 20 - - -def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None: - target = tmp_path / "my-app" - target.mkdir() - (target / "existing.txt").write_text("x") - - runner = CliRunner() - result = runner.invoke( - main, - ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - assert result.exit_code != 0 - assert "exists" in result.output.lower() or "exists" in (result.stderr or "").lower() diff --git a/framework/hosting/tests/test_host_cli.py b/framework/hosting/tests/test_host_cli.py new file mode 100644 index 00000000..f5a8b7ac --- /dev/null +++ b/framework/hosting/tests/test_host_cli.py @@ -0,0 +1,28 @@ +"""Smoke tests for the simple_module_hosting host_cli Typer plugin.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from simple_module_hosting.host_cli import app +from typer.testing import CliRunner + + +def test_app_is_typer_instance() -> None: + assert isinstance(app, typer.Typer) + + +def test_help_lists_gen_pages_and_sync_js_deps() -> None: + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "gen-pages" in result.output + assert "sync-js-deps" in result.output + + +def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower() diff --git a/modules/settings/pyproject.toml b/modules/settings/pyproject.toml index c9ad9c80..b3248444 100644 --- a/modules/settings/pyproject.toml +++ b/modules/settings/pyproject.toml @@ -24,13 +24,14 @@ dependencies = [ "simple_module_core==0.0.1", "simple_module_db==0.0.1", "simple_module_hosting==0.0.1", + "typer>=0.12", ] [project.entry-points.simple_module] settings = "settings.module:SettingsModule" -[project.scripts] -sm-settings = "settings.cli:main" +[project.entry-points."simple_module_cli.cli_plugins"] +settings = "settings.cli:app" [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" diff --git a/modules/settings/settings/cli.py b/modules/settings/settings/cli.py index eff67039..217d11d0 100644 --- a/modules/settings/settings/cli.py +++ b/modules/settings/settings/cli.py @@ -1,16 +1,16 @@ -"""``sm-settings`` CLI — currently only ``import-from-env``. +"""``sm settings`` plugin — currently only ``import-from-env``. -One-shot migration: walks every registered module's ``BaseSettings`` and, -for each field whose legacy ``SM__`` env var is set, writes -a SYSTEM-scoped override into the Settings store. +One-shot migration: walks every registered module's BaseSettings and +writes a SYSTEM-scoped override for each ``SM__`` env +var that is set. """ from __future__ import annotations import asyncio import os -import sys +import typer from fastapi import FastAPI from settings.constants import MODULE_PACKAGE @@ -18,14 +18,12 @@ from settings.hydrate import value_type_for_field from settings.store import SettingsStore +app = typer.Typer(help="Settings module administration.", no_args_is_help=True) -async def import_from_env_impl(app: FastAPI, store: SettingsStore) -> int: - """Write a SYSTEM override for every ``SM__`` env var set. - Returns the count of overrides written. Env vars that don't match a - registered field are ignored. - """ - registry = getattr(app.state, MODULE_PACKAGE).module_registry +async def import_from_env_impl(app_inst: FastAPI, store: SettingsStore) -> int: + """Write a SYSTEM override for every ``SM__`` env var set.""" + registry = getattr(app_inst.state, MODULE_PACKAGE).module_registry count = 0 for package, cls in registry.items(): prefix = env_prefix_for(package) @@ -39,40 +37,25 @@ async def import_from_env_impl(app: FastAPI, store: SettingsStore) -> int: return count -def main() -> int: - """Console-script entry point for ``sm-settings``. - - Supports a single subcommand: ``import-from-env``. - """ - argv = sys.argv[1:] - if not argv or argv[0] in ("-h", "--help"): - print("Usage: sm-settings import-from-env") - return 0 if argv else 1 - if argv[0] != "import-from-env": - print(f"Unknown command: {argv[0]}", file=sys.stderr) - print("Usage: sm-settings import-from-env", file=sys.stderr) - return 2 - +@app.command("import-from-env") +def import_from_env() -> None: + """Write SYSTEM overrides for every SM__ env var set.""" from simple_module_hosting.app_builder import create_app from simple_module_hosting.settings import Settings from settings.service import SettingService - app = create_app(Settings()) + fastapi_app = create_app(Settings()) async def run() -> int: async with ( - app.router.lifespan_context(app), - app.state.sm.db.session_factory() as session, + fastapi_app.router.lifespan_context(fastapi_app), + fastapi_app.state.sm.db.session_factory() as session, ): store = SettingsStore(SettingService(session)) - n = await import_from_env_impl(app, store) + n = await import_from_env_impl(fastapi_app, store) await session.commit() - print(f"Imported {n} override(s) from environment.") + typer.echo(f"Imported {n} override(s) from environment.") return 0 - return asyncio.run(run()) - - -if __name__ == "__main__": - sys.exit(main()) + raise typer.Exit(code=asyncio.run(run())) diff --git a/modules/users/pyproject.toml b/modules/users/pyproject.toml index 5d13b986..a6f50f0d 100644 --- a/modules/users/pyproject.toml +++ b/modules/users/pyproject.toml @@ -37,8 +37,8 @@ dependencies = [ [project.entry-points.simple_module] users = "users.module:UsersModule" -[project.scripts] -sm-users = "users.cli:app" +[project.entry-points."simple_module_cli.cli_plugins"] +users = "users.cli:app" [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" diff --git a/pyproject.toml b/pyproject.toml index c6201676..47958736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ line-length = 100 target-version = "py312" # Scaffold templates are copied verbatim (and some live under __PACKAGE__/ # placeholder dirs that aren't valid Python packages until substituted). -extend-exclude = ["framework/hosting/simple_module_hosting/templates"] +extend-exclude = ["framework/cli/simple_module_cli/templates"] [tool.ruff.lint] select = [ @@ -105,7 +105,7 @@ invalid-argument-type = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "modules/datasets/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "modules/datasets/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", diff --git a/scripts/new_module.py b/scripts/new_module.py index a133b544..a41f647b 100644 --- a/scripts/new_module.py +++ b/scripts/new_module.py @@ -19,6 +19,8 @@ import textwrap from pathlib import Path +from simple_module_cli.case import to_pascal_case + # Make sibling template modules importable when this file is run as a script. sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -54,11 +56,6 @@ def validate_name(name: str) -> str: return name -def to_class_name(name: str) -> str: - """Convert snake_case module name to PascalCase class name.""" - return "".join(word.capitalize() for word in name.split("_")) - - def to_singular(name: str) -> str: """Naive singularization: strip trailing 's' if present.""" if name.endswith("s") and not name.endswith("ss"): @@ -77,9 +74,9 @@ def _build_context(name: str) -> ScaffoldContext: singular = to_singular(name) return ScaffoldContext( name=name, - class_name=to_class_name(name), + class_name=to_pascal_case(name), singular=singular, - singular_class=to_class_name(singular), + singular_class=to_pascal_case(singular), pkg=name, ) diff --git a/scripts/tests/test_helpers.py b/scripts/tests/test_helpers.py index 14894dde..44754088 100644 --- a/scripts/tests/test_helpers.py +++ b/scripts/tests/test_helpers.py @@ -8,10 +8,10 @@ from new_module import ( _insert_after_last_match, create_file, - to_class_name, to_singular, validate_name, ) +from simple_module_cli.case import to_pascal_case class TestValidateName: @@ -41,15 +41,15 @@ def test_rejects_empty(self): validate_name("") -class TestToClassName: +class TestToPascalCase: def test_single_word(self): - assert to_class_name("orders") == "Orders" + assert to_pascal_case("orders") == "Orders" def test_two_words(self): - assert to_class_name("blog_posts") == "BlogPosts" + assert to_pascal_case("blog_posts") == "BlogPosts" def test_three_words(self): - assert to_class_name("user_role_assignments") == "UserRoleAssignments" + assert to_pascal_case("user_role_assignments") == "UserRoleAssignments" class TestToSingular: