simple-module-cli 0.0.19__tar.gz → 0.0.21__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/.gitignore +1 -0
  2. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/PKG-INFO +1 -1
  3. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/pyproject.toml +1 -1
  4. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/app_project.py +9 -5
  5. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/cli.py +25 -2
  6. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/scaffolding.py +35 -0
  7. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-cli/SKILL.md +51 -15
  8. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-conventions/SKILL.md +1 -1
  9. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-creating/SKILL.md +32 -18
  10. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-database/SKILL.md +3 -3
  11. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-doctor/SKILL.md +15 -10
  12. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +15 -11
  13. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-locales/SKILL.md +11 -2
  14. simple_module_cli-0.0.21/simple_module_cli/skills/simple-module-migrations/SKILL.md +124 -0
  15. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-registries/SKILL.md +20 -12
  16. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/simple-module-testing/SKILL.md +8 -7
  17. simple_module_cli-0.0.21/tests/test_create_module_ci_skip.py +107 -0
  18. simple_module_cli-0.0.19/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -103
  19. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/LICENSE +0 -0
  20. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/README.md +0 -0
  21. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/__init__.py +0 -0
  22. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/_env.py +0 -0
  23. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/case.py +0 -0
  24. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/catalog.py +0 -0
  25. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/new.py +0 -0
  26. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/package_update.py +0 -0
  27. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/pins.py +0 -0
  28. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/plugins.py +0 -0
  29. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/recipes.py +0 -0
  30. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills/README.md +0 -0
  31. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/skills_cmd.py +0 -0
  32. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/.env.example +0 -0
  33. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/.gitignore +0 -0
  34. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/Makefile +0 -0
  35. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/README.md.tpl +0 -0
  36. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  37. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
  38. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -0
  39. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  40. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
  41. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/alembic.ini +0 -0
  42. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  43. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  44. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  45. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  46. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  47. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  48. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  49. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  50. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/client_app/vite.config.ts +0 -0
  51. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/main.py +0 -0
  52. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/migrations/env.py +0 -0
  53. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  54. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  55. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  56. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/routes.py +0 -0
  57. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/host/templates/index.html +0 -0
  58. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  59. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  60. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/.gitignore +0 -0
  61. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/README.md.tpl +0 -0
  62. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  63. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  64. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  65. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  66. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
  67. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  68. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  69. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/package.json.tpl +0 -0
  70. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  71. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  72. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  73. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/workspace/.env.example +0 -0
  74. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/workspace/.gitignore +0 -0
  75. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/workspace/Makefile +0 -0
  76. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/workspace/README.md.tpl +0 -0
  77. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  78. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
  79. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/simple_module_cli/wizard.py +0 -0
  80. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_build_packaging.py +0 -0
  81. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_case.py +0 -0
  82. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_catalog.py +0 -0
  83. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_new.py +0 -0
  84. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_new_dest_tolerance.py +0 -0
  85. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_new_regressions.py +0 -0
  86. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_new_scaffold_layout.py +0 -0
  87. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_package_update.py +0 -0
  88. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_recipes.py +0 -0
  89. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_cli_wizard.py +0 -0
  90. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_env_helper.py +0 -0
  91. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_module_pages_manifest.py +0 -0
  92. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_no_framework_deps.py +0 -0
  93. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_plugin_discovery.py +0 -0
  94. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_scaffold_rollback.py +0 -0
  95. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_scaffolding_host.py +0 -0
  96. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_scaffolding_module.py +0 -0
  97. {simple_module_cli-0.0.19 → simple_module_cli-0.0.21}/tests/test_skills_cmd.py +0 -0
@@ -61,3 +61,4 @@ Thumbs.db
61
61
  .playwright-mcp/*
62
62
  host/client_app/.playwright-cli/*
63
63
  .superpowers/
64
+ .qa/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_cli
3
- Version: 0.0.19
3
+ Version: 0.0.21
4
4
  Summary: Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host.
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_cli"
3
- version = "0.0.19"
3
+ version = "0.0.21"
4
4
  description = "Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -15,7 +15,6 @@ from __future__ import annotations
15
15
 
16
16
  import json as _json
17
17
  import secrets as _secrets
18
- import shutil as _shutil
19
18
  from collections.abc import Sequence
20
19
  from pathlib import Path
21
20
  from typing import Any
@@ -193,10 +192,15 @@ def _scaffold_sample_module(target: Path) -> None:
193
192
  # Pin the sample's framework deps to the exact framework version so the
194
193
  # workspace resolves (the template's >=1.0,<2.0 ranges don't exist on PyPI
195
194
  # pre-1.0). See GH #195.
196
- create_module(sample_dest, name=_SAMPLE_MODULE_NAME, framework_version=_FRAMEWORK_VERSION)
197
- # GitHub only reads workflows from the repo root, so the template's
198
- # .github/ is dead inside a workspace.
199
- _shutil.rmtree(sample_dest / ".github")
195
+ # The sample lives inside the workspace, so it gets no per-module .github/:
196
+ # GitHub only reads workflows from the repo root, where the template's
197
+ # .github/ would be dead anyway. See GH #210.
198
+ create_module(
199
+ sample_dest,
200
+ name=_SAMPLE_MODULE_NAME,
201
+ framework_version=_FRAMEWORK_VERSION,
202
+ include_ci=False,
203
+ )
200
204
  _seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist")
201
205
 
202
206
 
@@ -23,7 +23,7 @@ from simple_module_cli.package_update import package_update
23
23
  from simple_module_cli.plugins import discover_and_mount
24
24
  from simple_module_cli.scaffolding import create_host as _create_host
25
25
  from simple_module_cli.scaffolding import create_module as _create_module
26
- from simple_module_cli.scaffolding import resolve_framework_version
26
+ from simple_module_cli.scaffolding import is_inside_existing_repo, resolve_framework_version
27
27
  from simple_module_cli.skills_cmd import app as skills_app
28
28
 
29
29
  app = typer.Typer(
@@ -90,21 +90,44 @@ def create_module(
90
90
  Path | None,
91
91
  typer.Option("--dest", help="Destination dir. Defaults to ./simple_module_<name>."),
92
92
  ] = None,
93
+ standalone: Annotated[
94
+ bool,
95
+ typer.Option(
96
+ "--standalone",
97
+ help="Emit the module's own .github/ CI + PyPI publish workflows. "
98
+ "By default they are omitted when the module lands inside an "
99
+ "existing repo/host (nested workflows never run there).",
100
+ ),
101
+ ] = False,
93
102
  ) -> None:
94
103
  """Scaffold a publishable SimpleModule module package."""
95
104
  slug = to_kebab_case(name)
96
105
  package = slug.replace("-", "_")
97
106
  target = dest or Path.cwd() / f"simple_module_{package}"
107
+ # An in-repo module (the documented modules/* layout) gets no .github/: those
108
+ # nested workflows never run and publish.yml is a PyPI footgun. --standalone
109
+ # forces them for a module that lives in its own repo. See GH #210.
110
+ include_ci = standalone or not is_inside_existing_repo(target)
98
111
  try:
99
112
  # Pin framework deps to the installed framework version so the module
100
113
  # resolves against the app that created it (the template's >=1.0,<2.0
101
114
  # ranges don't exist on PyPI pre-1.0). See GH #195.
102
- _create_module(target, name=name, framework_version=resolve_framework_version())
115
+ _create_module(
116
+ target,
117
+ name=name,
118
+ framework_version=resolve_framework_version(),
119
+ include_ci=include_ci,
120
+ )
103
121
  except FileExistsError as exc:
104
122
  typer.echo(f"ERROR: {exc}", err=True)
105
123
  raise typer.Exit(code=1) from exc
106
124
 
107
125
  typer.echo(f"Created module 'simple_module_{package}' at {target}")
126
+ if not include_ci:
127
+ typer.echo(
128
+ "Skipped .github/ workflows: this module is inside an existing repo, "
129
+ "where nested workflows never run. Use --standalone to emit them."
130
+ )
108
131
  typer.echo("\nNext steps:")
109
132
  typer.echo(f" cd {target}")
110
133
  typer.echo(" uv sync --extra dev")
@@ -37,6 +37,7 @@ __all__ = [
37
37
  "create_host",
38
38
  "create_module",
39
39
  "create_workspace",
40
+ "is_inside_existing_repo",
40
41
  "pin_framework_deps",
41
42
  "resolve_framework_version",
42
43
  ]
@@ -61,6 +62,31 @@ def _module_to_pypi_name(name: str) -> str:
61
62
  return f"simple_module_{name.lower()}"
62
63
 
63
64
 
65
+ def is_inside_existing_repo(dest: Path) -> bool:
66
+ """Return True when ``dest`` lands inside an existing repo / host project.
67
+
68
+ A module scaffolded under an existing host application (the documented
69
+ monorepo ``modules/*`` layout) is an *in-repo* module: GitHub only runs
70
+ workflows from the repository-root ``.github/workflows/``, so a per-module
71
+ ``.github/`` is dead weight there — and the bundled ``publish.yml`` (which
72
+ publishes ``simple_module_<name>`` to PyPI on any ``v*`` tag) is a footgun if
73
+ it ever surfaces at the repo root. We detect this by walking up from
74
+ ``dest``'s parent for a ``.git`` directory or a ``pyproject.toml`` (an
75
+ existing repo / host / workspace member).
76
+
77
+ ``dest`` itself is *excluded* from the walk — the module's own scaffolded
78
+ ``pyproject.toml`` must not count as "an existing host". A truly standalone
79
+ target (no repo/pyproject above it) returns False. See GH #210.
80
+ """
81
+ # ``resolve()`` allows ``dest`` to not exist yet; the walk is over its
82
+ # absolute parents so a relative ``--dest`` is handled the same way.
83
+ start = Path(dest).resolve().parent
84
+ for parent in (start, *start.parents):
85
+ if (parent / ".git").exists() or (parent / "pyproject.toml").is_file():
86
+ return True
87
+ return False
88
+
89
+
64
90
  def _should_pin_framework_version(version: str | None) -> bool:
65
91
  """Whether ``version`` is a concrete pin rather than a skip sentinel.
66
92
 
@@ -222,6 +248,7 @@ def create_module(
222
248
  template_root: Path | None = None,
223
249
  *,
224
250
  framework_version: str | None = None,
251
+ include_ci: bool = True,
225
252
  ) -> Path:
226
253
  """Scaffold a module package at ``dest``.
227
254
 
@@ -230,6 +257,12 @@ def create_module(
230
257
  the module resolves against that framework version (e.g. ``uv add`` into the
231
258
  workspace that created it). Left as ``None`` (or the ``"*"`` sentinel), the
232
259
  template's ranges are kept verbatim. See GH #195.
260
+
261
+ When ``include_ci`` is False, the scaffolded ``.github/`` (CI + PyPI publish
262
+ workflows) is omitted. Those nested workflows never run inside an existing
263
+ host repo (GitHub only reads the repo-root ``.github/``) and ``publish.yml``
264
+ is a footgun there, so callers creating an *in-repo* module pass
265
+ ``include_ci=False``. See GH #210.
233
266
  """
234
267
  dest = Path(dest)
235
268
  existed_before = dest.exists()
@@ -249,6 +282,8 @@ def create_module(
249
282
  },
250
283
  path_rewrites={_PACKAGE_PATH_TOKEN: package_name},
251
284
  )
285
+ if not include_ci:
286
+ shutil.rmtree(dest / ".github", ignore_errors=True)
252
287
  if _should_pin_framework_version(framework_version):
253
288
  pin_framework_deps(dest / "pyproject.toml", framework_version)
254
289
  except Exception:
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  name: simple-module-cli
3
- description: Use when invoking the `smpy` CLI for a simple_module_python project — starting a new app, scaffolding a host or a publishable module, regenerating the Inertia page manifest, importing settings overrides from env, creating an admin user, or installing the bundled agent skills. Triggers on "smpy new", "smpy create-host", "smpy create-module", "smpy host gen-pages", "smpy users create-admin", "smpy skills add", or any unfamiliar `smpy` subcommand.
3
+ description: Use when invoking the `smpy` CLI for a simple_module_python project — starting a new app, scaffolding a host or a publishable module, bumping simple_module_* dependencies to their latest PyPI versions, regenerating the Inertia page manifest, importing settings overrides from env, creating an admin user, or installing the bundled agent skills. Triggers on "smpy new", "smpy create-host", "smpy create-module", "smpy package-update", "smpy host gen-pages", "smpy users create-admin", "smpy skills add", or any unfamiliar `smpy` subcommand.
4
4
  ---
5
5
 
6
6
  # simple_module_python: the `smpy` CLI
7
7
 
8
- The `smpy` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`). It groups several kinds of operations: scaffolding new things, project-time helpers for the host, admin shortcuts for the bundled modules, installing the bundled agent skills, and bumping `simple_module_*` deps.
8
+ The `smpy` command is provided by `simple_module_cli` (installed as a dep of `simple_module_hosting`); `simple-module` is an identical alias for the same entry point. It groups several kinds of operations: scaffolding new things, project-time helpers for the host, admin shortcuts for the bundled modules, installing the bundled agent skills, and bumping `simple_module_*` deps.
9
+
10
+ `new`, `create-host`, `create-module`, `package-update`, and `skills` are built into `simple_module_cli` and always present. `host` / `settings` / `users` are **plugin subgroups** contributed by installed packages (`simple_module_hosting` / `simple_module_settings` / `simple_module_users`) through the `simple_module_cli.cli_plugins` entry-point group — they only show up when that package is installed, which is why a fresh bare host won't list `smpy users` until the users module is added.
9
11
 
10
12
  ## Top-level commands
11
13
 
@@ -24,19 +26,24 @@ The `smpy` command is provided by `simple_module_cli` (installed as a dep of `si
24
26
 
25
27
  The fastest way from zero to a working app. It calls `create-host` under the hood, then installs and wires up the modules you pick.
26
28
 
29
+ App names must be **lowercase** with a single separator style (`my_app`, `my-app`, or `myapp`) — `smpy new` rejects mixed case / spaces / mixed separators up front (`MyApp`, `foo_bar-baz` error out). `create-host` and `create-module` are lenient and accept any case.
30
+
27
31
  ```bash
28
32
  # Interactive (asks for DB, tenancy, preset, module list)
29
- smpy new MyApp
33
+ smpy new my_app
30
34
 
31
35
  # Non-interactive: take all defaults (sqlite, no tenancy, standard preset)
32
- smpy new MyApp --yes
36
+ smpy new my_app --yes
33
37
 
34
38
  # Pick a preset and add extras
35
- smpy new MyApp --preset full --tenancy --db postgres
36
- smpy new MyApp --preset minimal --with background_tasks,file_storage --yes
39
+ smpy new my_app --preset full --tenancy --db postgres
40
+ smpy new my_app --preset minimal --with background_tasks,file_storage --yes
37
41
 
38
- # Scaffold only skip uv sync / npm install / alembic upgrade head
39
- smpy new MyApp --no-install
42
+ # Consume-only host: no modules/ dir, no sample module to author against
43
+ smpy new my_app --preset standard --flat --yes
44
+
45
+ # Scaffold only — skip uv sync / npm install / the initial migration
46
+ smpy new my_app --no-install
40
47
  ```
41
48
 
42
49
  **Module presets:**
@@ -45,10 +52,14 @@ smpy new MyApp --no-install
45
52
  |---|---|
46
53
  | `minimal` | `users` (and `auth` as a dep) |
47
54
  | `standard` (default) | `users`, `dashboard`, `permissions` (+ deps) |
48
- | `full` | every module in the catalog |
49
- | `custom` | interactive — pick each module yes/no |
55
+ | `full` | every module **in the scaffolder catalog** (the 8 keys below) — *not* every module in the monorepo |
56
+ | `custom` | interactive — pick each catalog module yes/no |
57
+
58
+ `--preset` only accepts `minimal`, `standard`, or `full`. **`custom` is wizard-only** — there's no `--preset custom`; to hand-pick modules non-interactively, start from any preset and add the rest with `--with`.
50
59
 
51
- `--with` accepts a comma-separated list of catalog keys (`auth, users, permissions, dashboard, settings, feature_flags, file_storage, background_tasks`). Transitive `requires` are auto-added; the wizard prints `Added X (required by Y)` so you can see what got pulled in.
60
+ `--with` accepts a comma-separated list of catalog keys (`auth, users, permissions, dashboard, settings, feature_flags, file_storage, background_tasks`). Transitive `requires` are auto-added; the wizard prints `Added X (required by Y)` so you can see what got pulled in. An unknown key fails fast with the available list (so `--with=does_not_exist` is self-correcting). Note the catalog is a curated subset — repo modules like `products`, `keycloak`, and `audit_log` are **not** catalog keys; add those to an existing host by installing the package (see the pitfall below).
61
+
62
+ Selecting `background_tasks` also runs its post-scaffold **recipe**: it drops `docker-compose.yml`, `scripts/run_worker.py`, host/worker Dockerfiles, a `SM_BG_TASKS_BROKER_URL` entry in `.env.example`, and Celery `make` targets. The closing "Next steps" then reminds you to run `docker compose up -d redis worker beat` for the worker/beat processes.
52
63
 
53
64
  **Options summary:**
54
65
 
@@ -57,10 +68,13 @@ smpy new MyApp --no-install
57
68
  | `--dest <PATH>` | `./<name>` | Where to write the project |
58
69
  | `--db sqlite\|postgres` | `sqlite` | Backend configured in `.env.example` |
59
70
  | `--tenancy / --no-tenancy` | `--no-tenancy` | Enable the multi-tenant middleware |
60
- | `--preset minimal\|standard\|full` | wizard asks | Module bundle |
71
+ | `--preset minimal\|standard\|full` | wizard asks | Module bundle (no `custom` — that's wizard-only) |
61
72
  | `--with <names>` | none | Extra catalog keys beyond the preset |
73
+ | `--flat` | off | Skip the `modules/` directory and the sample module — for a host that only ever consumes published modules and never authors its own |
62
74
  | `--yes / -y` | off | Skip prompts; accept defaults |
63
- | `--no-install` | off | Skip `uv sync` / `npm install` / `alembic upgrade head` |
75
+ | `--no-install` | off | Skip `uv sync` / `npm install` / the autogenerated initial migration; the printed next steps use `make migration` / `make migrate` / `make dev` instead |
76
+
77
+ Passing `--preset` or any `--with` value switches `smpy new` into flag-driven mode (no wizard) even without `--yes`; DB and tenancy then come from `--db` / `--tenancy` defaults. The validated name is also normalized to a PyPI-safe kebab-case slug — `smpy new my_app` prints `Normalizing PyPI name to 'my-app'.` and uses that for the package metadata.
64
78
 
65
79
  ## `smpy create-host <name>` — bare host
66
80
 
@@ -70,7 +84,7 @@ smpy create-host MyApp --with Auth,Products # declare module deps in pyproject.
70
84
  smpy create-host MyApp --dest ./apps/myapp # custom destination
71
85
  ```
72
86
 
73
- `--with` takes **PascalCase module names** (matching `ModuleMeta.name`), not catalog keys. Use this when you want to drive the build yourself rather than via `smpy new`'s wizard.
87
+ `--with` takes **PascalCase module names** (matching `ModuleMeta.name`), not catalog keys — each is lowercased into a `simple_module_<name>` dependency in `pyproject.toml`. Unlike `smpy new`, the names are **not** checked against the scaffolder catalog, so any published module works (e.g. `--with Products` → `simple_module_products`). No deps are installed and no transitive `requires` are resolved; you drive `uv sync` and migrations yourself. Use this when you want to wire the build by hand rather than via `smpy new`'s wizard.
74
88
 
75
89
  ## `smpy create-module <name>` — module package
76
90
 
@@ -87,6 +101,25 @@ The result is a complete package: `pyproject.toml` with the entry point declared
87
101
 
88
102
  For the post-scaffold steps (entry point, Inertia namespace, etc.) see **simple-module-creating**.
89
103
 
104
+ ## `smpy package-update` — bump `simple_module_*` deps
105
+
106
+ Walks the project's `pyproject.toml` (plus every `[tool.uv.workspace]` member), finds each dependency whose distribution name matches `simple_module_*` / `simple-module-*`, queries PyPI for the latest non-yanked release, and rewrites the constraint to `name>=<latest>`. PyPI lookups run in parallel, so it's quick even across a multi-package workspace.
107
+
108
+ ```bash
109
+ smpy package-update # rewrite constraints in ./pyproject.toml (+ workspace members)
110
+ smpy package-update --dry-run # show "old → new" per file, write nothing
111
+ smpy package-update --include-pre # also consider pre-releases (a/b/rc/dev/post)
112
+ smpy package-update --path apps/web # point at another project root or a pyproject.toml
113
+ ```
114
+
115
+ | Flag | Default | Meaning |
116
+ |---|---|---|
117
+ | `--path <DIR\|FILE>` | cwd | Project root or an explicit `pyproject.toml` |
118
+ | `--dry-run` | off | Print planned changes only; don't write |
119
+ | `--include-pre` | off | Allow pre-release versions as the "latest" |
120
+
121
+ **Deps it deliberately skips:** anything whose `[tool.uv.sources]` entry points at a workspace member, a local `path`, a `git` ref, or a `url` — those don't come from PyPI, so their version lives elsewhere. Skipped deps are listed with a reason (`workspace/local source`, `not found on PyPI`). The command only edits constraints; **run `uv sync` afterward** to actually install the new versions (it reminds you to).
122
+
90
123
  ## `smpy skills` — install the bundled agent skills
91
124
 
92
125
  `simple_module_cli` ships a set of [SKILL.md](https://agentskills.io/specification) packs (the ones in this directory). Drop them into any project so Claude Code / Cursor / Codex / etc. find them automatically.
@@ -124,6 +157,7 @@ Wheel-installed modules ship `package.json` declarations that need to land in th
124
157
  ```bash
125
158
  smpy host sync-js-deps # uses ./client_app
126
159
  smpy host sync-js-deps --host-client-app=apps/web/client_app
160
+ smpy host sync-js-deps --dry-run # print the npm install command without running it
127
161
  ```
128
162
 
129
163
  Run after `pip install <module>` if the new module ships frontend code.
@@ -163,9 +197,11 @@ Don't bake `--password` literals into a script you commit; use a secrets store a
163
197
  - **Forgot `smpy host sync-js-deps` after `pip install`-ing a module with pages.** Vite resolves module imports against `client_app/node_modules`; the new module's JS deps won't land until you sync.
164
198
  - **Used `smpy create-module` to add a module to an existing host.** That command is for **publishable** packages, intended to live in their own repo. To add a module to an existing host: install it (`pip install simple_module_<name>` or add to `pyproject.toml` and `uv sync`), then autogenerate a migration. See **simple-module-creating** + **simple-module-migrations**.
165
199
  - **Calling `smpy create-admin` before migrations have run.** The users tables don't exist yet; the command will error. Run `alembic upgrade head` first (or use `smpy new` which does it for you when `--no-install` isn't set).
200
+ - **Expecting `--with products` (or `keycloak` / `audit_log`) to work on `smpy new`.** Those modules exist in the monorepo but aren't scaffolder-catalog keys, so `smpy new --with products` errors. Either scaffold without them and `pip install simple_module_products` into the host afterward, or use `smpy create-host --with Products` (which doesn't validate against the catalog).
201
+ - **`smpy package-update` "did nothing" for a workspace dep.** Deps sourced from a `[tool.uv.sources]` workspace/path/git/URL entry are intentionally skipped (their version isn't on PyPI) — they show up in the output as `skipped`, not updated. Also remember to run `uv sync` after; `package-update` only rewrites the constraint strings.
166
202
 
167
203
  ## Related skills
168
204
 
169
205
  - **simple-module-creating** — what `smpy create-module` produces and the post-scaffold contract
170
206
  - **simple-module-inertia-pages** — what `smpy host gen-pages` regenerates and why
171
- - **simple-module-migrations** — the `alembic upgrade head` step `smpy new` runs
207
+ - **simple-module-migrations** — the migration step `smpy new` runs (`make migration` + `make migrate`, or `alembic upgrade head` directly)
@@ -89,7 +89,7 @@ Locale-related diagnostics: SM013 (missing file), SM014 (missing keys vs default
89
89
 
90
90
  ### 9. Don't re-enable the silenced ty rules
91
91
 
92
- Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, and `invalid-argument-type` in `pyproject.toml`. SQLModel runtime-instruments fields as SQLAlchemy attributes, so the type checker can't see what's there. Real bugs surface in tests.
92
+ Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type`, and `invalid-assignment` in `pyproject.toml`. SQLModel runtime-instruments fields as SQLAlchemy attributes, so the type checker can't see what's there (and `model_config = ConfigDict(...)` clashes with ty's internal `SQLModelConfig` type, which `invalid-assignment` covers). Real bugs surface in tests.
93
93
 
94
94
  ## Related skills
95
95
 
@@ -10,35 +10,49 @@ description: Use when adding a new feature package to a simple_module_python app
10
10
  ## Quick path
11
11
 
12
12
  ```bash
13
- # scaffold a publishable module package in ./orders (run in a fresh repo or
14
- # inside a host's modules/ directory)
15
- smpy create-module orders
16
-
17
- # install the new package into the host environment
18
- uv sync # or: pip install -e ./orders
13
+ # scaffold a module into this repo's modules/orders/ also wires the package
14
+ # into host/ + root pyproject.toml and runs `uv sync --all-packages`
15
+ make new-module name=orders
19
16
 
20
17
  # if you added a frontend page, regenerate the Inertia manifest
21
- smpy host gen-pages --host-dir=client_app
18
+ make gen-pages
22
19
 
23
20
  # if you added SQLModel tables, autogenerate + apply a migration
24
- uv run alembic revision --autogenerate -m "add orders module"
25
- uv run alembic upgrade head
21
+ make migration msg="add orders module"
22
+ make migrate
26
23
  ```
27
24
 
25
+ `make new-module` (→ `scripts/new_module.py`) is the in-repo path and produces
26
+ the full tree below. For a **standalone publishable package** (its own repo,
27
+ with CI + PyPI `publish.yml`), use `smpy create-module orders` instead — a
28
+ leaner scaffold; pass `--standalone` to force the `.github/` workflows, then
29
+ `uv sync` (or `pip install -e ./orders`) to install it into the host.
30
+
28
31
  ## What scaffolding produces
29
32
 
30
33
  ```
31
- orders/
34
+ modules/orders/
32
35
  ├── pyproject.toml # entry point declared here
36
+ ├── package.json # per-module JS deps
37
+ ├── tsconfig.json
38
+ ├── tests/test_orders.py
33
39
  └── orders/
34
40
  ├── module.py # OrdersModule(ModuleBase) with ModuleMeta
35
41
  ├── models.py # Base = create_module_base("orders")
36
- ├── contracts/ # SQLModel DTOs — public surface
42
+ ├── contracts/schemas.py # SQLModel DTOs — public surface
43
+ ├── service.py # business logic
44
+ ├── services.py # app.state.orders state container (settings)
45
+ ├── settings.py # SM_ORDERS_* env → dataclass
46
+ ├── deps.py # FastAPI dependencies
37
47
  ├── endpoints/{api,views}.py
38
- ├── pages/ # *.tsx, auto-discovered by Vite
48
+ ├── pages/{Browse,Create,Edit}.tsx # *.tsx, auto-discovered by Vite
39
49
  └── locales/en.json
40
50
  ```
41
51
 
52
+ `make new-module` also edits `host/pyproject.toml` (adds the workspace dep) and
53
+ the root `pyproject.toml` (type-check + test paths), so the module is picked up
54
+ by the host and by `make lint`/`make test` without further wiring.
55
+
42
56
  ## The contract: pyproject.toml + module.py
43
57
 
44
58
  The host discovers modules via a single entry point. If this is wrong, the module silently does nothing in dev and **fails boot in production** (strict discovery).
@@ -56,7 +70,7 @@ orders = "orders.module:OrdersModule"
56
70
 
57
71
  ```python
58
72
  # modules/orders/orders/module.py
59
- from simple_module_core import ModuleBase, ModuleMeta
73
+ from simple_module_core.module import ModuleBase, ModuleMeta
60
74
 
61
75
  class OrdersModule(ModuleBase):
62
76
  meta = ModuleMeta(
@@ -64,13 +78,12 @@ class OrdersModule(ModuleBase):
64
78
  route_prefix="/api/orders",
65
79
  view_prefix="/orders",
66
80
  depends_on=[], # other module names (PascalCase)
67
- version="0.1.0",
68
81
  )
69
82
  ```
70
83
 
71
84
  `ModuleMeta.name` is load-bearing in two places: the `__tablename__` prefix you author and the PascalCase Inertia component namespace. So directory `blog_posts` → `name="BlogPosts"` → `inertia.render("BlogPosts/Index", ...)` → `pages/Index.tsx`. Mismatches fire diagnostic codes `SM003` (orphan page) / `SM004` (phantom render).
72
85
 
73
- For modules you intend to publish, also add `version=` (your module's semver) and `requires_framework=` (a PEP 440 spec for the framework API range, e.g. `">=1.0,<2.0"`) so the host can reject incompatible installs at boot.
86
+ `ModuleMeta` also accepts `version=` (defaults to `"1.0.0"`) and `requires_framework=` (a PEP 440 spec for the framework API range, e.g. `">=1.0,<2.0"`) so the host can reject incompatible installs at boot — set these on modules you intend to publish.
74
87
 
75
88
  ## Lifecycle hooks (override only what you need)
76
89
 
@@ -84,6 +97,7 @@ In execution order — all no-op by default:
84
97
  | `register_feature_flags(registry)` | `FeatureFlagDefinition` constants |
85
98
  | `register_event_handlers(bus)` | `bus.subscribe(EventCls, handler)` |
86
99
  | `register_health_checks(registry)` | Module-owned health probes |
100
+ | `register_public_routes(registry)` | Exempt routes from auth via `add_prefix` / `add_regex` (method-aware) |
87
101
  | `register_exception_handlers(app)` | Module-specific error mapping |
88
102
  | `register_middleware(app)` | LIFO — module middleware sorted last wraps outermost |
89
103
  | `register_routes(api_router, view_router)` | `include_router(...)` your two routers |
@@ -91,14 +105,14 @@ In execution order — all no-op by default:
91
105
 
92
106
  ## Verify after scaffolding
93
107
 
94
- Boot the host. Diagnostics run automatically: in development, warnings/errors land in the boot logs; in production (`SM_ENVIRONMENT != development`), errors fail the boot. Codes `SM001`/`SM008`/`SM009` are blocking; `SM007` (module overrides no hooks) is info-only.
108
+ Boot the host. The diagnostics pass runs **only in development** warnings/errors print to the boot logs and any ERROR raises `SystemExit`. In **production** the diagnostics pass is skipped; the one thing that fails a production boot is strict module *discovery* (a bad entry point — broken import, missing `meta`, non-`ModuleBase` class), surfaced as `SM001`. So `SM008`/`SM009` are dev-boot / `make doctor` errors, not production-boot blockers; `SM007` (module overrides no hooks) is info-only.
95
109
 
96
- The new module should appear in the registered-modules log line. If it has a view endpoint, visit `<view_prefix>/`.
110
+ The new module should appear in the registered-modules log line. If it has a view endpoint, visit `<view_prefix>/`. Run `make doctor` for the same diagnostics out of band (orphan pages, coupling, migration drift, locale checks) — see the **simple-module-doctor** skill for the full code list.
97
111
 
98
112
  ## Pitfalls
99
113
 
100
114
  - **Forgot the entry point.** Package installs, module silently doesn't load (production strict mode raises `InvalidModuleError`). Verify `[project.entry-points.simple_module]` exists in `pyproject.toml`.
101
- - **`name=` collides.** Two modules with the same `ModuleMeta.name` raise `SM008` at boot.
115
+ - **`name=` collides.** Two modules with the same `ModuleMeta.name` raise `SM008` at dev boot / `make doctor` (their lowercased names would also collide as table prefixes in the shared schema).
102
116
  - **Registered the module by hand in host code.** Don't — discovery is entry-point-only; host code never imports module code.
103
117
 
104
118
  For framework-wide rules that apply once the module exists (SQLModel-everywhere, file-size cap, settings layout, no `session.commit()` in services), see the **simple-module-conventions** skill. For migration mechanics see **simple-module-migrations**.
@@ -31,9 +31,9 @@ All modules — on Postgres and SQLite alike — share the host's single schema.
31
31
 
32
32
  | Mixin | Adds | Behavior |
33
33
  |---|---|---|
34
- | `AuditMixin` | `created_at`, `updated_at`, `created_by`, `updated_by` | Auto-populated by SQLAlchemy listeners from the current user/timestamp. |
34
+ | `AuditMixin` | `created_at`, `updated_at`, `created_by`, `updated_by` | `created_at` defaults Python-side (and via `server_default`); `updated_at`/`created_by`/`updated_by` set by the `before_flush` listener from `current_user_id` + timestamp. |
35
35
  | `SoftDeleteMixin` | `is_deleted`, `deleted_at`, `deleted_by` | `session.delete(obj)` → soft-delete; `SELECT` auto-filters deleted rows. |
36
- | `MultiTenantMixin` | `tenant_id` | Auto-populated on insert; `SELECT` auto-scoped when `current_tenant_id` is set. |
36
+ | `MultiTenantMixin` | `tenant_id` | Auto-populated on insert from `current_tenant_id`. Column is **non-nullable**, so inserting outside any tenant context fails at the DB; creating/mutating a row for a different tenant raises `TenantIsolationError`. `SELECT` auto-scoped when `current_tenant_id` is set. |
37
37
  | `VersionedMixin` | `version: int` | Auto-incremented on update. Use for optimistic concurrency. |
38
38
 
39
39
  List `Base` first; Python MRO handles mixin ordering after that:
@@ -77,7 +77,7 @@ async def create_order(session: AsyncSession, payload: OrderCreate) -> Order:
77
77
 
78
78
  ## Pitfalls
79
79
 
80
- - **Setting `tenant_id` manually.** `MultiTenantMixin` populates it on insert from request state. Setting it yourself fights the listener and produces wrong tenant attribution.
80
+ - **Setting `tenant_id` manually.** `MultiTenantMixin` populates it on insert from request state (`current_tenant_id`). Setting it to a *different* tenant than the active context raises `TenantIsolationError`; changing it on an existing row raises too.
81
81
  - **Querying `WHERE is_deleted = false` by hand.** The listener already does it. Use `execution_options(include_deleted=True)` only when you genuinely want deleted rows.
82
82
 
83
83
  ## Related skills
@@ -5,22 +5,24 @@ description: Use when interpreting a simple_module_python diagnostic code (SM001
5
5
 
6
6
  # simple_module_python: diagnostics
7
7
 
8
- Diagnostics run automatically at host boot. **In development**, warnings and errors land in the boot logs and the host keeps running. **In production** (`SM_ENVIRONMENT != development`), errors fail the boot by design, because a module silently dropping out in prod is worse than a crash.
8
+ `make doctor` runs `python -m simple_module_core`: it discovers modules **strictly**, runs the diagnostics, prints findings to stderr (`✓ No module diagnostics issues found` when clean), and exits `1` if any ERROR-level finding was reported.
9
9
 
10
- You can also call `run_diagnostics(...)` from `simple_module_core` programmatically (e.g. in a CI job) to surface issues without booting the full app.
10
+ The same diagnostics also run at host boot, but **only in development** (`SM_ENVIRONMENT == development`) — they print to the boot logs and `raise SystemExit` if there's any ERROR. **In production the diagnostics pass is skipped**; what protects prod is that module *discovery* runs strictly (any bad entry point — broken import, missing `meta`, non-`ModuleBase` class — raises and fails the boot). So "errors fail prod boot" is true for discovery-level breakage (surfaced as **SM001**), not for the per-finding checks below, which you'll catch via `make doctor` / dev boot before you ever deploy.
11
+
12
+ You can also call `run_diagnostics(...)` from `simple_module_core` programmatically (e.g. in a CI job) to surface issues without booting the full app — pass `migration_state=` / `module_tables=` / `migrated_tables=` to additionally get **SM010/SM011** (the migration checks below), and `i18n_supported_locales=` / `i18n_default_locale=` to get the locale checks.
11
13
 
12
14
  ## Code reference
13
15
 
14
16
  | Code | Level | What it means | Fix |
15
17
  |---|---|---|---|
16
- | **SM001** | ERROR | Module class is missing `meta = ModuleMeta(...)` or it's malformed | Add a valid `meta` class attribute on the `ModuleBase` subclass |
18
+ | **SM001** | ERROR | Module class is missing its `meta = ModuleMeta(...)` class attribute, or (under strict discovery) the entry point failed to load — broken import, missing `meta`, or a class that isn't a `ModuleBase` subclass | Add a valid `meta` class attribute on the `ModuleBase` subclass / fix the entry point. This is the one code that fails *production* boot (via strict discovery) |
17
19
  | **SM003** | WARNING | A `pages/<Name>.tsx` exists but no `inertia.render("<Module>/<Name>", ...)` references it | Either delete the orphan file or wire up the render call |
18
20
  | **SM004** | WARNING | `inertia.render("<Module>/<Name>", ...)` is called but no matching `.tsx` exists | Fix the render-key typo or create the page file |
19
21
  | **SM007** | INFO | Module overrides no `register_*` hooks at all — likely scaffolded but empty | Either implement at least one hook or delete the module if unused |
20
- | **SM008** | ERROR | Two modules declare the same `ModuleMeta.name` (their lowercased names would collide as table prefixes in the shared schema) | Rename one module's `meta.name` |
22
+ | **SM008** | ERROR | Two modules declare the same `ModuleMeta.name`, or two names collide once lowercased as table prefixes in the shared schema (one finding per collision, from either the duplicate-name or the schema-conflict check) | Rename one module's `meta.name` |
21
23
  | **SM009** | ERROR | A `framework/*` package directly imports from a plugin module (`modules/*`) | Move the symbol *up* into `simple_module_core` / `simple_module_hosting`, or invert the dependency via the event bus / a registry |
22
- | **SM010** | ERROR | Live DB revision is behind the migration head | Run `alembic upgrade head` before booting; in CI, ensure migrations are part of the deploy step |
23
- | **SM011** | WARNING | A module declares a SQLModel table that has no entry in migration history | Run `alembic revision --autogenerate -m "..."` and apply |
24
+ | **SM010** | ERROR | Live DB revision is behind the migration head | Run `make migrate` before booting; in CI, ensure migrations are part of the deploy step. *Note:* only emitted when a caller passes `migration_state=` to `run_diagnostics` — `make doctor` and dev boot do **not** check this. The live boot enforces it separately: `check_migrations()` raises a plain `RuntimeError` (not an SM code) in dev and prod if the DB is behind head |
25
+ | **SM011** | WARNING | A module declares a SQLModel table that has no entry in migration history | Run `make migration msg="..."` and apply. *Note:* only emitted when `module_tables=`/`migrated_tables=` are passed to `run_diagnostics`; not surfaced by `make doctor` or boot |
24
26
  | **SM012** | WARNING (dev only) | `register_settings` is overridden but nothing was added to `app.state.<module_lower>` | Either store the module's settings dataclass on `app.state.<module_lower>` or remove the empty override |
25
27
  | **SM013** | WARNING | A locale file declared in `locale_dirs()` is missing for one of the supported locales | Create the file (even if empty) or trim `SM_I18N_SUPPORTED_LOCALES` |
26
28
  | **SM014** | WARNING | A non-default locale is missing keys present in the default locale | Add the missing keys, or accept that a fallback to default applies |
@@ -32,13 +34,16 @@ You can also call `run_diagnostics(...)` from `simple_module_core` programmatica
32
34
  | **SM020** | ERROR | More than one auth provider module is installed | Install exactly one auth provider (e.g. `users` OR `keycloak`, not both) |
33
35
  | **SM021** | WARNING | No auth provider module is installed | Install an auth provider module (e.g. `simple-module-users` or `simple-module-keycloak`) |
34
36
 
35
- Codes `SM002`, `SM005`, `SM006`, `SM012` are not part of this table `SM002`/`SM005`/`SM006` are reserved/retired, and `SM012` (`register_settings` overridden but nothing on `app.state.<module_lower>`, WARNING, dev boot only) is raised from the hosting layer, not the core diagnostics runner. Output format is one line per finding, e.g. `[SM009] ERROR: <subject>`.
37
+ `SM002`, `SM005`, `SM006` don't exist in the implementationthey're reserved/retired and never emitted. `SM012` is the one code that doesn't come from the core diagnostics runner: it's raised from the hosting layer (`check_settings_registration`, after Phase 4 settings registration) and so is only ever seen at **dev boot**, not via `make doctor`.
38
+
39
+ Each finding prints across one to three lines, formatted as `<glyph> <CODE> [<LEVEL>] <module_name>: <message>`, with the glyph `✗` (error) / `⚠` (warning) / `ℹ` (info) — e.g. `✗ SM009 [ERROR] users: Framework package '...' directly imports from module package '...'`. A `↳ <file>` line and a `↳ Suggestion: <text>` line follow when present. The run ends with `Results: N error(s), N warning(s), N info`.
36
40
 
37
- Warnings are load-bearing: the framework only emits one when something concrete *will* break under a specific condition (locale switch, schema downgrade, deploy ordering). Ignored long enough they become the next on-call page. If you're suppressing warnings in CI to make it green, you're trading the CI signal for a production-boot failure later.
41
+ Warnings are load-bearing: the framework only emits one when something concrete *will* break under a specific condition (locale switch, schema downgrade, deploy ordering). They don't fail the prod boot — that's exactly why they're easy to ignore until the condition hits in production and becomes the next on-call page. If you're suppressing warnings in CI to make it green, you're trading the CI signal for that later runtime break.
38
42
 
39
43
  ## Related skills
40
44
 
41
- - **simple-module-creating** — fixing SM001/SM007/SM008
45
+ - **simple-module-creating** — fixing SM001/SM007/SM008/SM017/SM019/SM020/SM021
42
46
  - **simple-module-database** + **simple-module-migrations** — fixing SM010/SM011
43
47
  - **simple-module-inertia-pages** — fixing SM003/SM004/SM018
44
- - **simple-module-conventions** — fixing SM009/SM012 and locale codes
48
+ - **simple-module-locales** — fixing SM013/SM014/SM015/SM016
49
+ - **simple-module-conventions** — fixing SM009/SM012
@@ -15,7 +15,7 @@ description: Use when adding or debugging an Inertia.js page in a simple_module_
15
15
  | Module page (snake-case dir) | `inertia.render("BlogPosts/Index", ...)` | `<module_pkg>/blog_posts/pages/Index.tsx` |
16
16
  | Host page | `inertia.render("Landing", ...)` | `<host>/client_app/pages/Landing.tsx` |
17
17
 
18
- The namespace is **PascalCase of the module's directory name**, not the file system path. Directory `blog_posts` → `BlogPosts`. The framework's manifest generator (`smpy host gen-pages`) wires up Vite's `import.meta.glob` to resolve these keys at runtime.
18
+ The namespace is **`ModuleMeta.name`** — by default the PascalCase of the module's directory name (the scaffolder runs `to_pascal_case`), not the file system path. Directory `blog_posts` → `BlogPosts`. The manifest generator keys each module's glob by `meta.name` (`compute_module_pages` in `simple_module_hosting/manifest.py`), and `smpy host gen-pages` wires up Vite's `import.meta.glob` so the resolver (`host/client_app/pages.ts`) maps `"<meta.name>/<PageName>"` → the page module. A `.tsx` under a subdirectory of `pages/` is keyed by its relative path (`pages/admin/Edit.tsx` → `"<meta.name>/admin/Edit"`).
19
19
 
20
20
  After adding or renaming a `.tsx`, regenerate the manifest:
21
21
 
@@ -31,21 +31,21 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t
31
31
 
32
32
  | Prop | Shape | Populated from |
33
33
  |---|---|---|
34
- | `auth.user` | object or `null` | A `principal_serializer` callable on `app.state` (registered by the auth/users module's `register_settings`) |
34
+ | `auth.user` | object or `null` | A `principal_serializer` callable on `app.state` (registered by the `auth` module's `register_settings`) |
35
35
  | `auth.isAuthenticated` | bool | `request.state.user` presence |
36
36
  | `auth.permissions` | string[] | Roles → permissions expansion via the framework's permission registry |
37
- | `menus` | `{ sidebar, adminSidebar, navbar, userDropdown }` | All modules' `register_menu_items()` output, role-filtered |
38
- | `i18n` | `{ locale, translations }` | Active locale (`request.state.locale`) + flattened bundle for that locale |
37
+ | `menus` | `{ sidebar, adminSidebar, navbar, userDropdown }` | All modules' `register_menu_items()` output, auth/role-filtered |
38
+ | `i18n` | `{ locale, supportedLocales, messages }` | Active locale (`request.state.locale`) + flattened message bundle for that locale (`messages` is `null` on Inertia XHR partials when the locale is unchanged) |
39
39
 
40
- **`auth.user` is `None` when no `principal_serializer` is registered**, even if the user is authenticated. The framework can't know the shape of your user object. The auth/users module is responsible for registering the callable during `register_settings(app)`:
40
+ **`auth.user` is `None` when no `principal_serializer` is registered**, even if the user is authenticated. The framework can't know the shape of your user object. The `auth` module registers the callable during `register_settings(app)` (`modules/auth/auth/module.py`):
41
41
 
42
42
  ```python
43
- def serialize_principal(user) -> dict:
44
- return {"id": user.id, "email": user.email, "name": user.name}
43
+ def _serialize_principal(user: UserContext) -> dict:
44
+ return {"id": user.id, "name": user.name, "email": user.email, "roles": user.roles}
45
45
 
46
- class UsersModule(ModuleBase):
46
+ class AuthModule(ModuleBase):
47
47
  def register_settings(self, app: FastAPI) -> None:
48
- app.state.principal_serializer = serialize_principal
48
+ app.state.principal_serializer = _serialize_principal
49
49
  ```
50
50
 
51
51
  ## Endpoint pattern
@@ -70,7 +70,10 @@ The `view_router` (mounted at `view_prefix`) is for HTML/Inertia. The `api_route
70
70
 
71
71
  Inertia's `router.post(...)` / `.patch(...)` / `.put(...)` / `.delete(...)` expects an Inertia response back, not JSON. If a page calls `router.post("/api/orders", ...)`, Inertia rejects the JSON response and the form silently fails.
72
72
 
73
- For JSON endpoints, use plain `fetch()`:
73
+ Two fixes, in preference order:
74
+
75
+ 1. **Stay Inertia-native** — point the call at a *view* endpoint (under `view_prefix`, e.g. `/orders/...`) that returns `RedirectResponse(..., status_code=303)`. Inertia follows the redirect and re-renders the page.
76
+ 2. **Need the JSON payload back** — use plain `fetch()` against the `/api/*` endpoint instead of Inertia's `router`:
74
77
 
75
78
  ```tsx
76
79
  await fetch("/api/orders", {
@@ -86,8 +89,9 @@ await fetch("/api/orders", {
86
89
 
87
90
  - **Translated strings in props built at module scope.** `const labels = { title: t("orders.title") }` freezes against the first render's locale — build per-request translations inside the handler or via shared props.
88
91
  - **Imported a page TSX from another page.** Manifest-wired pages break if you import them directly; production builds drop the side-effect imports.
92
+ - **Pages exist but are unreachable in the UI (`SM019`).** A module with a non-empty `view_prefix` that overrides `register_routes` but neither `register_menu_items` nor `register_permissions` ships pages with no sidebar entry and no role-editor visibility. Add a menu item, register permissions, or clear `view_prefix` if it's API-only.
89
93
 
90
94
  ## Related skills
91
95
 
92
96
  - **simple-module-creating** — where the PascalCase rule originates (matching `ModuleMeta.name`)
93
- - **simple-module-doctor** — full reference for `SM003` / `SM004` / `SM018`
97
+ - **simple-module-doctor** — full reference for `SM003` / `SM004` / `SM018` / `SM019`
@@ -96,7 +96,16 @@ export function useOrderSchema() {
96
96
 
97
97
  ## Backend usage
98
98
 
99
- Server-side, translated strings come from the same bundle via `request.state.locale` resolution. Avoid hard-coding English in error responses that surface to users pull the message through the locale system so other languages get it for free.
99
+ Inject `TranslatorDep` (from `simple_module_hosting.i18n_deps`) into an endpoint to get a `Translator` bound to `request.state.locale` (set by `LocaleMiddleware`). Call `t.t(key, **params)` same `{name}` placeholders, and pass `count=` for CLDR pluralization:
100
+
101
+ ```python
102
+ from simple_module_hosting.i18n_deps import TranslatorDep
103
+
104
+ async def create(t: TranslatorDep):
105
+ raise HTTPException(404, t.t("orders.errors.not_found", id=order_id))
106
+ ```
107
+
108
+ Resolution falls back to the default locale, then to the bare key. Avoid hard-coding English in error responses that surface to users — pull the message through the locale system so other languages get it for free.
100
109
 
101
110
  ## Diagnostic codes
102
111
 
@@ -107,7 +116,7 @@ Server-side, translated strings come from the same bundle via `request.state.loc
107
116
  | **SM015** | WARNING | A non-default locale has keys **not** in the default | Either remove the dead keys or add them to the default file |
108
117
  | **SM016** | ERROR | A locale JSON file is invalid or has non-string leaves | Fix the JSON. Only string leaves are allowed — interpolation is `{placeholder}` strings, not nested objects |
109
118
 
110
- SM016 is fatal in production. SM013–SM015 are warnings, but they're the kind of warnings that turn into "Spanish users see English in production" if ignored.
119
+ SM016 is an ERROR that fails **dev boot** and `make doctor` (exit 1) — in production the diagnostics pass is skipped, so a malformed locale won't crash a prod boot; catch it before deploy via `make doctor` / dev boot. SM013–SM015 are warnings, but they're the kind that turn into "Spanish users see English in production" if ignored.
111
120
 
112
121
  ## Pitfalls
113
122