simple-module-cli 0.0.35__tar.gz → 0.0.37__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 (146) hide show
  1. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/.gitignore +1 -0
  2. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/PKG-INFO +1 -1
  3. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/pyproject.toml +1 -1
  4. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/_env.py +8 -1
  5. simple_module_cli-0.0.37/simple_module_cli/_local_env.py +83 -0
  6. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/add_cmd.py +36 -5
  7. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/app_project.py +34 -11
  8. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/cli.py +26 -0
  9. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/docker_assets.py +3 -2
  10. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/new.py +1 -0
  11. simple_module_cli-0.0.37/simple_module_cli/openapi_cmd.py +81 -0
  12. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/pyproject_edit.py +19 -10
  13. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-doctor/SKILL.md +3 -1
  14. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-migrations/SKILL.md +7 -9
  15. simple_module_cli-0.0.37/simple_module_cli/templates/docker/Makefile.snippet +25 -0
  16. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl +5 -3
  17. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl +3 -3
  18. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl +1 -1
  19. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/.env.example +3 -2
  20. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/.gitignore +1 -3
  21. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/Makefile +21 -2
  22. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/README.md.tpl +1 -1
  23. simple_module_cli-0.0.37/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +24 -0
  24. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/app.tsx +11 -1
  25. simple_module_cli-0.0.37/simple_module_cli/templates/host/client_app/pages/Error.tsx +1 -0
  26. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/migrations/env.py +15 -3
  27. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/migrations/script.py.mako +1 -0
  28. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/routes.py +1 -1
  29. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/templates/index.html +1 -0
  30. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +1 -1
  31. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/tests/test___PACKAGE__.py.tpl +5 -2
  32. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/.env.example +3 -2
  33. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/.gitignore +1 -3
  34. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/Makefile +19 -6
  35. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/README.md.tpl +9 -10
  36. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/pyproject.toml.tpl +3 -0
  37. simple_module_cli-0.0.37/simple_module_cli/uv_workspace.py +57 -0
  38. simple_module_cli-0.0.37/tests/test_cli_add_workspace_member.py +112 -0
  39. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_docker_assets.py +82 -2
  40. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_new.py +55 -19
  41. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_new_regressions.py +13 -0
  42. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_new_scaffold_layout.py +32 -0
  43. simple_module_cli-0.0.37/tests/test_cli_openapi_export.py +32 -0
  44. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_recipes.py +16 -2
  45. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_module_css_build.py +3 -0
  46. simple_module_cli-0.0.37/tests/test_module_css_subdir_sources.py +47 -0
  47. simple_module_cli-0.0.37/tests/test_scaffolded_host_setup_wizard.py +57 -0
  48. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_scaffolding_host.py +2 -0
  49. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_scaffolding_module.py +10 -0
  50. simple_module_cli-0.0.35/simple_module_cli/templates/docker/Makefile.snippet +0 -13
  51. simple_module_cli-0.0.35/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -26
  52. simple_module_cli-0.0.35/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -13
  53. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/LICENSE +0 -0
  54. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/README.md +0 -0
  55. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/__init__.py +0 -0
  56. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/_module_build.py +0 -0
  57. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/_module_host.py +0 -0
  58. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/_templating.py +0 -0
  59. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/case.py +0 -0
  60. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/catalog.py +0 -0
  61. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/git_source.py +0 -0
  62. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/module_cmd.py +0 -0
  63. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/package_update.py +0 -0
  64. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/pins.py +0 -0
  65. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/plugins.py +0 -0
  66. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/pypi.py +0 -0
  67. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/recipes.py +0 -0
  68. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/requirements.py +0 -0
  69. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/scaffolding.py +0 -0
  70. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/README.md +0 -0
  71. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  72. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  73. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  74. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  75. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  76. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  77. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  78. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  79. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/skills_cmd.py +0 -0
  80. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl +0 -0
  81. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/dockerignore +0 -0
  82. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/host-flat.Dockerfile +0 -0
  83. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/host.Dockerfile +0 -0
  84. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  85. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/alembic.ini +0 -0
  86. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  87. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/module-assets.ts +0 -0
  88. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  89. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  90. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  91. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  92. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  93. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/vite.config.ts +0 -0
  94. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/vite.dev-url.ts +0 -0
  95. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/main.py +0 -0
  96. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  97. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  98. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  99. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  100. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/.gitignore +0 -0
  101. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/README.md.tpl +0 -0
  102. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  103. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  104. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  105. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl +0 -0
  106. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/pages/Index.tsx.tpl +0 -0
  107. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  108. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  109. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/_optional/standalone/package.json.tpl +0 -0
  110. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/_optional/standalone/tsconfig.json.tpl +0 -0
  111. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/package.json.tpl +0 -0
  112. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  113. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  114. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  115. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/update_cmd.py +0 -0
  116. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/simple_module_cli/wizard.py +0 -0
  117. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/conftest.py +0 -0
  118. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_build_packaging.py +0 -0
  119. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_case.py +0 -0
  120. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_add_cmd.py +0 -0
  121. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_add_spec_parse.py +0 -0
  122. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_catalog.py +0 -0
  123. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_git_scan.py +0 -0
  124. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_new_dest_tolerance.py +0 -0
  125. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_new_git_module.py +0 -0
  126. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_new_migration_bootstrap.py +0 -0
  127. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_package_update.py +0 -0
  128. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_package_update_pins.py +0 -0
  129. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_pyproject_edit.py +0 -0
  130. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_update_sources.py +0 -0
  131. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_vite_port_env.py +0 -0
  132. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_wizard.py +0 -0
  133. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_cli_wizard_git.py +0 -0
  134. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_create_module_ci_skip.py +0 -0
  135. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_env_helper.py +0 -0
  136. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_module_cmd_build.py +0 -0
  137. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_module_cmd_e2e.py +0 -0
  138. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_module_cmd_verify.py +0 -0
  139. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_module_css_assets.py +0 -0
  140. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_module_npm_aliases.py +0 -0
  141. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_module_pages_manifest.py +0 -0
  142. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_no_framework_deps.py +0 -0
  143. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_plugin_discovery.py +0 -0
  144. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_scaffold_rollback.py +0 -0
  145. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_scaffolding_module_js.py +0 -0
  146. {simple_module_cli-0.0.35 → simple_module_cli-0.0.37}/tests/test_skills_cmd.py +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_cli
3
- Version: 0.0.35
3
+ Version: 0.0.37
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.35"
3
+ version = "0.0.37"
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"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __all__ = ["set_env_key"]
5
+ __all__ = ["env_values", "set_env_key"]
6
6
 
7
7
 
8
8
  def set_env_key(text: str, key: str, value: str) -> str:
@@ -10,3 +10,10 @@ def set_env_key(text: str, key: str, value: str) -> str:
10
10
  lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")]
11
11
  lines.append(f"{key}={value}")
12
12
  return "\n".join(lines) + "\n"
13
+
14
+
15
+ def env_values(text: str) -> dict[str, str]:
16
+ """Parse the ``KEY=VALUE`` lines of an env-style file body (comments skipped)."""
17
+ lines = text.splitlines()
18
+ pairs = (ln.split("=", 1) for ln in lines if "=" in ln and not ln.lstrip().startswith("#"))
19
+ return {key.strip(): value for key, value in pairs}
@@ -0,0 +1,83 @@
1
+ """Split a new app's env file into a committed template and a local ``.env``.
2
+
3
+ ``.env.example`` is committed, so it only ever carries placeholders. The
4
+ secrets ``smpy new`` generates go to ``.env``, which every scaffold's
5
+ ``.gitignore`` excludes (GH #441). An existing ``.env`` is never overwritten:
6
+ only keys it does not define yet are added.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ from collections.abc import Iterable
13
+ from pathlib import Path
14
+
15
+ from simple_module_cli._env import env_values, set_env_key
16
+
17
+ __all__ = ["add_postgres_password_placeholder", "comment_out_env_key", "write_local_env"]
18
+
19
+ _PG_PASSWORD_NOTE = (
20
+ "# Compose puts this into SM_DATABASE_URL unencoded: use only URL-safe\n"
21
+ "# characters (letters, digits, - _ . ~). `make docker-up` rejects others (GH #472).\n"
22
+ )
23
+
24
+ _SECRET_NOTE = (
25
+ "# Secrets — `smpy new` generated these into .env (gitignored); keep them\n"
26
+ "# out of this committed file. Set real values wherever you deploy."
27
+ )
28
+
29
+
30
+ def comment_out_env_key(text: str, key: str) -> str:
31
+ """Turn ``KEY=...`` into a bare ``# KEY=`` placeholder, in place if present."""
32
+ lines = text.splitlines()
33
+ placeholder = f"# {key}="
34
+ hits = [i for i, ln in enumerate(lines) if ln.startswith((f"{key}=", placeholder))]
35
+ if hits:
36
+ lines[hits[0]] = placeholder
37
+ lines = [ln for i, ln in enumerate(lines) if i not in hits[1:]]
38
+ else:
39
+ if _SECRET_NOTE.splitlines()[0] not in lines:
40
+ lines += ["", *_SECRET_NOTE.splitlines()]
41
+ lines.append(placeholder)
42
+ return "\n".join(lines) + "\n"
43
+
44
+
45
+ def add_postgres_password_placeholder(text: str) -> str:
46
+ """Add an empty ``POSTGRES_PASSWORD=`` to ``.env.example``, with its URL-safe note."""
47
+ text = set_env_key(text, "POSTGRES_PASSWORD", "")
48
+ return text.replace("POSTGRES_PASSWORD=\n", _PG_PASSWORD_NOTE + "POSTGRES_PASSWORD=\n", 1)
49
+
50
+
51
+ def write_local_env(
52
+ target: Path, *, secret_keys: Iterable[str], overrides: dict[str, str] | None = None
53
+ ) -> None:
54
+ """Write ``target/.env`` from ``.env.example`` plus generated secrets.
55
+
56
+ ``secret_keys`` get a fresh random value each; ``overrides`` are written
57
+ verbatim (e.g. a database URL carrying a generated password). Overrides
58
+ belong together, so an existing ``.env`` defining any of them gets none:
59
+ a fresh URL must not disagree with a kept ``POSTGRES_PASSWORD``.
60
+ """
61
+ env_path = target / ".env"
62
+ existing = env_path.exists()
63
+ source = env_path if existing else target / ".env.example"
64
+ text = source.read_text(encoding="utf-8") if source.exists() else ""
65
+ defined = env_values(text).keys()
66
+ overrides = overrides or {}
67
+ if existing and defined & overrides.keys():
68
+ overrides = {}
69
+ values = {key: secrets.token_urlsafe(32) for key in secret_keys} | overrides
70
+ for key, value in values.items():
71
+ if existing and key in defined:
72
+ continue
73
+ placeholder = f"# {key}="
74
+ lines = text.splitlines()
75
+ if placeholder in lines:
76
+ # Line-based, so a placeholder on the last line (no trailing
77
+ # newline) is handled too.
78
+ lines[lines.index(placeholder)] = f"{key}={value}"
79
+ text = "\n".join(lines) + "\n"
80
+ else:
81
+ text = set_env_key(text, key, value)
82
+ env_path.write_text(text, encoding="utf-8")
83
+ env_path.chmod(0o600) # holds generated secrets
@@ -11,6 +11,7 @@ sync-js-deps`` → entry-point verification, unless ``--no-sync``.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import os
14
15
  import subprocess
15
16
  import tempfile
16
17
  from collections.abc import Callable
@@ -35,7 +36,9 @@ from simple_module_cli.pyproject_edit import (
35
36
  write_dependency,
36
37
  write_git_source,
37
38
  write_path_source,
39
+ write_workspace_source,
38
40
  )
41
+ from simple_module_cli.uv_workspace import is_workspace_member
39
42
 
40
43
  __all__ = ["ExecRunner", "add_module", "post_install", "run_add"]
41
44
 
@@ -137,18 +140,46 @@ def _add_pypi(doc, pyproject: Path, raw: str, no_sync: bool, exec_runner: ExecRu
137
140
  return [name]
138
141
 
139
142
 
143
+ def _resolve_local_path(project_dir: Path, spec_path: Path) -> tuple[Path, str]:
144
+ """Resolve a local-path spec to ``(directory, text written to pyproject)``.
145
+
146
+ A relative path is resolved against the ``--path`` project (the pyproject
147
+ being edited, which is what ``[tool.uv.sources]`` paths are relative to).
148
+ If nothing exists there but the path exists relative to the cwd, the cwd
149
+ one is used and the written path is re-expressed relative to the project.
150
+ """
151
+ if spec_path.is_absolute():
152
+ return spec_path, str(spec_path)
153
+ root = project_dir / spec_path
154
+ if not root.exists() and (Path.cwd() / spec_path).exists():
155
+ root = Path.cwd() / spec_path
156
+ return root, os.path.relpath(root.resolve(), project_dir.resolve())
157
+ return root, str(spec_path)
158
+
159
+
140
160
  def _add_path(
141
161
  doc, pyproject: Path, spec_path: Path, no_sync: bool, exec_runner: ExecRunner
142
162
  ) -> list[str]:
143
- root = spec_path if spec_path.is_absolute() else pyproject.parent / spec_path
163
+ root, written = _resolve_local_path(pyproject.parent, spec_path)
144
164
  found = [m for m in scan_modules(root) if m.subdirectory is None]
145
165
  if not found:
146
- raise _fail(f"{root} has no [project.entry-points.simple_module] entry point at its root")
166
+ raise _fail(
167
+ f"{root} has no [project.entry-points.simple_module] entry point at its root "
168
+ f"(relative paths resolve against the --path project, {pyproject.parent}, "
169
+ "falling back to the current directory)"
170
+ )
147
171
  mod = found[0]
148
- write_dependency(doc, mod.dist_name, derive_range(mod.version))
149
- write_path_source(doc, mod.dist_name, path=str(spec_path))
172
+ if is_workspace_member(pyproject.parent, root):
173
+ # uv refuses a path source for a workspace member; the scaffold's own
174
+ # sample module is wired the same way (unpinned + workspace = true).
175
+ write_dependency(doc, mod.dist_name, "")
176
+ write_workspace_source(doc, mod.dist_name)
177
+ typer.echo(f"Added {mod.dist_name} (uv workspace member {written}).")
178
+ else:
179
+ write_dependency(doc, mod.dist_name, derive_range(mod.version))
180
+ write_path_source(doc, mod.dist_name, path=written)
181
+ typer.echo(f"Added {mod.dist_name} (editable path {written}).")
150
182
  save_pyproject(pyproject, doc)
151
- typer.echo(f"Added {mod.dist_name} (editable path {spec_path}).")
152
183
  if not no_sync:
153
184
  post_install(
154
185
  pyproject.parent,
@@ -20,7 +20,12 @@ from pathlib import Path
20
20
  from typing import Any
21
21
 
22
22
  from simple_module_cli._env import set_env_key
23
- from simple_module_cli.case import to_kebab_case, to_pascal_case
23
+ from simple_module_cli._local_env import (
24
+ add_postgres_password_placeholder,
25
+ comment_out_env_key,
26
+ write_local_env,
27
+ )
28
+ from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case
24
29
  from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
25
30
  from simple_module_cli.docker_assets import scaffold_docker_assets
26
31
  from simple_module_cli.recipes import RECIPES, ScaffoldCtx
@@ -52,6 +57,8 @@ _APP_PY_DEV_DEPS = [
52
57
  "pytest>=8.0",
53
58
  "pytest-asyncio>=0.24",
54
59
  "pytest-playwright>=0.7.2",
60
+ # Floor: unpinned, uv backtracks playwright to 1.18 (GH #445).
61
+ "playwright>=1.45",
55
62
  "ruff>=0.8",
56
63
  "ty>=0.0.29",
57
64
  ]
@@ -150,19 +157,33 @@ def create_app_project(
150
157
  py_deps.append(_SAMPLE_MODULE_PKG)
151
158
  workspace_sources.append(_SAMPLE_MODULE_PKG)
152
159
 
153
- env_path = target / ".env.example"
154
- env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
155
- env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32))
160
+ # Generated secrets go to the gitignored .env, never the committed
161
+ # .env.example (GH #441) — see write_local_env below.
162
+ secret_keys = ["SM_SECRET_KEY"]
156
163
  if "users" in resolved:
157
164
  # UsersSettings refuses its placeholder token secrets when
158
165
  # SM_ENVIRONMENT=production — the mode the compose containers run
159
- # in — so generate real ones, same as SM_SECRET_KEY above.
160
- for key in (
166
+ # in — so generate real ones, same as SM_SECRET_KEY.
167
+ secret_keys += [
161
168
  "SM_USERS_RESET_PASSWORD_TOKEN_SECRET",
162
169
  "SM_USERS_VERIFICATION_TOKEN_SECRET",
163
- ):
164
- env_text = set_env_key(env_text, key, _secrets.token_urlsafe(32))
165
- env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat))
170
+ ]
171
+ db_name = to_snake_case(name)
172
+ env_path = target / ".env.example"
173
+ env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
174
+ for key in secret_keys:
175
+ env_text = comment_out_env_key(env_text, key)
176
+ local_overrides: dict[str, str] = {}
177
+ if db == "postgres":
178
+ # The compose `postgres` service takes its password from .env, and
179
+ # the dev SM_DATABASE_URL must match it (GH #444).
180
+ env_text = add_postgres_password_placeholder(env_text)
181
+ password = _secrets.token_urlsafe(24)
182
+ local_overrides = {
183
+ "POSTGRES_PASSWORD": password,
184
+ "SM_DATABASE_URL": _db_url(db, db_name, flat=flat, password=password),
185
+ }
186
+ env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, db_name, flat=flat))
166
187
  env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
167
188
  env_path.write_text(env_text, encoding="utf-8")
168
189
 
@@ -198,6 +219,8 @@ def create_app_project(
198
219
  RECIPES[recipe_key].apply(target, ctx)
199
220
 
200
221
  scaffold_docker_assets(target, ctx, flat=flat)
222
+ # Last, so .env picks up every key the recipes added to .env.example.
223
+ write_local_env(target, secret_keys=secret_keys, overrides=local_overrides)
201
224
 
202
225
  return host_dir, preserved
203
226
 
@@ -234,9 +257,9 @@ def _seed_static_dist_placeholder(static_dist: Path) -> None:
234
257
  (static_dist / ".gitkeep").touch()
235
258
 
236
259
 
237
- def _db_url(db: str, slug: str, *, flat: bool) -> str:
260
+ def _db_url(db: str, db_name: str, *, flat: bool, password: str = "<POSTGRES_PASSWORD>") -> str:
238
261
  if db == "postgres":
239
- return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}"
262
+ return f"postgresql+asyncpg://postgres:{password}@localhost:5432/{db_name}"
240
263
  # Workspace mode keeps the SQLite file next to host/'s alembic.ini so
241
264
  # `cd host && uvicorn` and `cd host && alembic` resolve the same path.
242
265
  return "sqlite+aiosqlite:///./app.db" if flat else "sqlite+aiosqlite:///./host/app.db"
@@ -14,6 +14,7 @@ group are mounted as named subgroups (e.g. ``smpy host gen-pages``).
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import os
17
18
  from pathlib import Path
18
19
  from typing import Annotated
19
20
 
@@ -23,6 +24,7 @@ from simple_module_cli.add_cmd import add_module, run_add
23
24
  from simple_module_cli.case import to_kebab_case
24
25
  from simple_module_cli.module_cmd import module_app
25
26
  from simple_module_cli.new import new_project
27
+ from simple_module_cli.openapi_cmd import openapi_app
26
28
  from simple_module_cli.package_update import package_update
27
29
  from simple_module_cli.plugins import discover_and_mount
28
30
  from simple_module_cli.scaffolding import create_host as _create_host
@@ -30,6 +32,7 @@ from simple_module_cli.scaffolding import create_module as _create_module
30
32
  from simple_module_cli.scaffolding import is_inside_existing_repo, resolve_framework_version
31
33
  from simple_module_cli.skills_cmd import app as skills_app
32
34
  from simple_module_cli.update_cmd import update_modules
35
+ from simple_module_cli.uv_workspace import find_workspace_root, is_workspace_member
33
36
 
34
37
  app = typer.Typer(
35
38
  help="SimpleModule developer CLI.",
@@ -43,6 +46,7 @@ app.command("update")(update_modules)
43
46
  app.command("package-update")(package_update)
44
47
  app.add_typer(skills_app, name="skills")
45
48
  app.add_typer(module_app, name="module")
49
+ app.add_typer(openapi_app, name="openapi")
46
50
 
47
51
 
48
52
  @app.command("create-host")
@@ -103,6 +107,27 @@ def create_host(
103
107
  typer.echo(" python main.py")
104
108
 
105
109
 
110
+ def _echo_workspace_hint(target: Path) -> None:
111
+ """Tell the user how to wire a workspace-member module into the host (GH #442)."""
112
+ root = find_workspace_root(target.parent)
113
+ if root is None:
114
+ return
115
+ host = root / "host"
116
+ if not (host / "pyproject.toml").is_file() or not is_workspace_member(host, target):
117
+ return
118
+ rel = os.path.relpath(target.resolve(), host.resolve())
119
+ if not rel.startswith("."):
120
+ rel = f"./{rel}" # parse_add_spec reads a bare "a/b" as a PyPI requirement
121
+ host_arg = os.path.relpath(host.resolve(), Path.cwd())
122
+ if host_arg.startswith(".."):
123
+ host_arg = str(host.resolve())
124
+ typer.echo(
125
+ f"\nThis module is a uv workspace member. Wire it into the host with:\n"
126
+ f" smpy add {rel} --path {host_arg}"
127
+ " # adds it as { workspace = true }"
128
+ )
129
+
130
+
106
131
  @app.command("create-module")
107
132
  def create_module(
108
133
  name: Annotated[str, typer.Argument(help="Module name (any case).")],
@@ -149,6 +174,7 @@ def create_module(
149
174
  "Skipped .github/ workflows: this module is inside an existing repo, "
150
175
  "where nested workflows never run. Use --standalone to emit them."
151
176
  )
177
+ _echo_workspace_hint(target)
152
178
  typer.echo("\nNext steps:")
153
179
  typer.echo(f" cd {target}")
154
180
  typer.echo(" uv sync --extra dev")
@@ -19,7 +19,7 @@ import importlib.resources
19
19
  import shutil
20
20
  from pathlib import Path
21
21
 
22
- from simple_module_cli.case import to_kebab_case
22
+ from simple_module_cli.case import to_snake_case
23
23
  from simple_module_cli.recipes import ScaffoldCtx
24
24
 
25
25
  __all__ = ["scaffold_docker_assets"]
@@ -70,7 +70,8 @@ def scaffold_docker_assets(target: Path, ctx: ScaffoldCtx, *, flat: bool = False
70
70
  # compiles to DEFAULT 0 on SQLite, which Postgres rejects), so pointing
71
71
  # a sqlite-scaffolded app at a Postgres container fails on first boot.
72
72
  db = "postgres" if ctx.db == "postgres" else "sqlite"
73
- pg_db = to_kebab_case(ctx.name)
73
+ # snake_case: a kebab name needs quoting in every psql call (GH #443).
74
+ pg_db = to_snake_case(ctx.name)
74
75
  with_tasks = "background_tasks" in ctx.selected
75
76
  compose = (
76
77
  _read(templates / f"compose-base-{db}.yml.tpl")
@@ -170,6 +170,7 @@ def new_project(
170
170
  for path in preserved:
171
171
  rel = path.relative_to(target) if path.is_relative_to(target) else path
172
172
  typer.echo(f" {rel}")
173
+ typer.echo("\nGenerated secrets were written to .env (gitignored), not .env.example.")
173
174
  typer.echo("\nNext steps:")
174
175
  typer.echo(f" cd {target}")
175
176
  if no_install:
@@ -0,0 +1,81 @@
1
+ """``smpy openapi`` — export the host's OpenAPI document without a server (#456).
2
+
3
+ The app is built in process with ``create_app`` and its schema is written to a
4
+ file, so CI can generate API clients from a checkout. ``simple_module_hosting``
5
+ is imported inside the command: the CLI package depends only on typer and
6
+ tomlkit (``test_no_framework_deps``), and the hosting stack exists only in a
7
+ host's environment (run via ``uv run smpy openapi export``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import typer
18
+
19
+ openapi_app = typer.Typer(help="OpenAPI schema tools.", no_args_is_help=True)
20
+
21
+
22
+ def _norm(name: str) -> str:
23
+ """``feature_flags`` and the ``FeatureFlags`` tag compare equal."""
24
+ return name.replace("_", "").replace("-", "").lower()
25
+
26
+
27
+ def _filter_modules(schema: dict, modules: list[str]) -> dict:
28
+ """Keep operations whose first tag is one of ``modules`` (case and underscores ignored)."""
29
+ wanted = {_norm(m) for m in modules}
30
+ paths: dict = {}
31
+ for path, item in schema.get("paths", {}).items():
32
+ kept = {
33
+ method: op
34
+ for method, op in item.items()
35
+ if isinstance(op, dict) and _norm((op.get("tags") or [""])[0]) in wanted
36
+ }
37
+ if kept:
38
+ paths[path] = kept
39
+ return {**schema, "paths": paths}
40
+
41
+
42
+ @openapi_app.command("export")
43
+ def export(
44
+ out: Annotated[Path, typer.Option("--out", help="File to write.")] = Path("openapi.json"),
45
+ module: Annotated[
46
+ list[str] | None,
47
+ typer.Option("--module", help="Only include this module's operations. Repeatable."),
48
+ ] = None,
49
+ ) -> None:
50
+ """Write the host's OpenAPI document (``/api/**`` only) to ``--out``."""
51
+ project_root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
52
+ os.environ.setdefault("SM_PROJECT_ROOT", str(project_root))
53
+
54
+ try:
55
+ from simple_module_core.dotenv import load_dotenv_into_environ
56
+ from simple_module_hosting import create_app, merge_host_settings
57
+ from simple_module_hosting._preapp_config import load_host_overrides
58
+ from simple_module_hosting.bootstrap_settings import BootstrapSettings
59
+ except ImportError:
60
+ typer.echo(
61
+ "error: simple_module_hosting is not installed — run this from a host "
62
+ "project (`uv run smpy openapi export`).",
63
+ err=True,
64
+ )
65
+ raise typer.Exit(code=1) from None
66
+
67
+ load_dotenv_into_environ(project_root / ".env")
68
+ if not load_host_overrides(BootstrapSettings().database_url):
69
+ typer.echo(
70
+ "warning: no host settings found in the database (unreachable, not migrated or "
71
+ "empty); using environment and defaults for api_title / api_version.",
72
+ err=True,
73
+ )
74
+ settings = merge_host_settings()
75
+ schema = create_app(settings).openapi()
76
+ if module:
77
+ schema = _filter_modules(schema, module)
78
+
79
+ out.parent.mkdir(parents=True, exist_ok=True)
80
+ out.write_text(json.dumps(schema, indent=2, sort_keys=False) + "\n", encoding="utf-8")
81
+ typer.echo(f"wrote {out} ({len(schema.get('paths', {}))} paths)")
@@ -24,6 +24,7 @@ __all__ = [
24
24
  "write_dependency",
25
25
  "write_git_source",
26
26
  "write_path_source",
27
+ "write_workspace_source",
27
28
  ]
28
29
 
29
30
 
@@ -90,6 +91,15 @@ def _uv_sources(doc: tomlkit.TOMLDocument) -> Table:
90
91
  return uv.setdefault("sources", tomlkit.table())
91
92
 
92
93
 
94
+ def _set_source(doc: tomlkit.TOMLDocument, dist_name: str, src: Any) -> bool:
95
+ """Write ``src`` under ``[tool.uv.sources]``; False when already identical."""
96
+ sources = _uv_sources(doc)
97
+ if dist_name in sources and dict(sources[dist_name]) == dict(src):
98
+ return False
99
+ sources[dist_name] = src
100
+ return True
101
+
102
+
93
103
  def write_git_source(
94
104
  doc: tomlkit.TOMLDocument,
95
105
  dist_name: str,
@@ -104,22 +114,21 @@ def write_git_source(
104
114
  src[ref_info.kind] = ref_info.value
105
115
  if subdirectory:
106
116
  src["subdirectory"] = subdirectory
107
- sources = _uv_sources(doc)
108
- if dist_name in sources and dict(sources[dist_name]) == dict(src):
109
- return False
110
- sources[dist_name] = src
111
- return True
117
+ return _set_source(doc, dist_name, src)
112
118
 
113
119
 
114
120
  def write_path_source(doc: tomlkit.TOMLDocument, dist_name: str, *, path: str) -> bool:
115
121
  src = tomlkit.inline_table()
116
122
  src["path"] = path
117
123
  src["editable"] = True
118
- sources = _uv_sources(doc)
119
- if dist_name in sources and dict(sources[dist_name]) == dict(src):
120
- return False
121
- sources[dist_name] = src
122
- return True
124
+ return _set_source(doc, dist_name, src)
125
+
126
+
127
+ def write_workspace_source(doc: tomlkit.TOMLDocument, dist_name: str) -> bool:
128
+ """``dist = { workspace = true }`` — required for uv workspace members."""
129
+ src = tomlkit.inline_table()
130
+ src["workspace"] = True
131
+ return _set_source(doc, dist_name, src)
123
132
 
124
133
 
125
134
  def _sources_dict(doc: tomlkit.TOMLDocument) -> dict[str, Any]:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: simple-module-doctor
3
- description: Use when interpreting a simple_module_python diagnostic code (SM001–SM021) printed at boot or by the diagnostics runner — what the code means, whether it's blocking, and the concrete fix. Triggers on any "SMnnn" code in logs, "InvalidModuleError", "module silently doesn't load", or "production failed boot".
3
+ description: Use when interpreting a simple_module_python diagnostic code (SM001–SM029) printed at boot or by the diagnostics runner — what the code means, whether it's blocking, and the concrete fix. Triggers on any "SMnnn" code in logs, "InvalidModuleError", "module silently doesn't load", or "production failed boot".
4
4
  ---
5
5
 
6
6
  # simple_module_python: diagnostics
@@ -33,6 +33,8 @@ You can also call `run_diagnostics(...)` from `simple_module_core` programmatica
33
33
  | **SM019** | WARNING | A module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` — its pages exist with no sidebar entry and no role-editor visibility | Add `register_menu_items()` (sidebar) or `register_permissions()` (role editor), or clear `view_prefix` if the module is API-only |
34
34
  | **SM020** | ERROR | More than one auth provider module is installed | Install exactly one auth provider (e.g. `users` OR `keycloak`, not both) |
35
35
  | **SM021** | WARNING | No auth provider module is installed | Install an auth provider module (e.g. `simple-module-users` or `simple-module-keycloak`) |
36
+ | **SM028** | WARNING | A module's `tasks.py` imports `torch`, `tensorflow`, `ultralytics`, `osgeo`, `rasterio`, `cv2` or `sklearn` at module level | Import the package inside the task function, so only a worker that runs the task loads it |
37
+ | **SM029** | INFO | A module overrides `db_extensions()` (Postgres extensions like `ltree`) but the database is SQLite, which has none; `ensure_db_extensions` raises `UnsupportedDatabaseError` at migrate time | Point `SM_DATABASE_URL` at PostgreSQL |
36
38
 
37
39
  `SM002`, `SM005`, `SM006` don't exist in the implementation — they'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
40
 
@@ -64,7 +64,7 @@ make migration msg="add orders module"
64
64
  make migrate
65
65
  ```
66
66
 
67
- `make migrate` runs `alembic -c host/alembic.ini upgrade heads` — note **`heads`** (plural). Branch labels create multiple independent heads, so `upgrade head` (singular) would error or under-apply.
67
+ `make migrate` runs `alembic -c host/alembic.ini upgrade heads` — note **`heads`** (plural). Histories with a branch label can end up with multiple heads, so `upgrade head` (singular) may error or under-apply.
68
68
 
69
69
  ### Branch labels — set on the FIRST revision per module
70
70
 
@@ -78,16 +78,14 @@ branch_labels = ("audit_log",) # ← add by hand on this first revision
78
78
  depends_on = None
79
79
  ```
80
80
 
81
- Why: it lets operators roll back **just one module's** schema, which is how a module is uninstalled cleanly:
81
+ Why: the label is a named target for that revision (`upgrade audit_log@head`, the module column in the Doctor). It does **not** isolate the module: autogenerate chains the revision off the current head, so history is one linear chain and `downgrade audit_log@base` walks the **entire chain beneath it, other modules included**. Never use it as a per-module rollback.
82
82
 
83
- ```bash
84
- make downgrade # back one revision; OR, to a target:
85
- uv run --project host alembic -c host/alembic.ini downgrade audit_log@base
86
- ```
83
+ To remove one module's schema:
87
84
 
88
- `audit_log@base` drops everything that module owns and leaves the others. Without the label, downgrading to a bare revision id walks linear history and rolls back unrelated modules' migrations sitting between.
85
+ - If the module's revisions are the latest in the chain, downgrade to the `down_revision` of its first revision: `uv run --project host alembic -c host/alembic.ini downgrade <that down_revision id>`.
86
+ - Otherwise write a dedicated migration that drops that module's tables.
89
87
 
90
- Subsequent revisions for the same module **don't** need a `branch_labels` — only the first; they inherit the branch.
88
+ Subsequent revisions for the same module **don't** need a `branch_labels` — only the first; they chain off it as ordinary revisions.
91
89
 
92
90
  > Exception: the repo's bootstrap `initial_schema` revision bundles several core modules (users, permissions, settings, file_storage, feature_flags, background_tasks) into one unlabeled root migration. The per-module `branch_labels` convention applies to modules added **after** that bootstrap.
93
91
 
@@ -112,7 +110,7 @@ SM010/SM011 are **not** emitted at boot or by `make doctor`. They only surface w
112
110
 
113
111
  ## Pitfalls
114
112
 
115
- - **Skipped the branch label on the first revision.** `alembic downgrade <module>@base` then fails or silently rolls back unrelated revisions. Fix by editing the migration file before applying; if already deployed, write a no-op revision that adds the label retroactively.
113
+ - **Treating `downgrade <module>@base` as a per-module rollback.** It rolls back every revision beneath the labelled one, other modules included. See the recipe under "Branch labels".
116
114
  - **Renamed a column.** Autogenerate emits `drop_column` + `add_column`, which loses data. Edit to `op.alter_column(..., new_column_name=...)` and write the matching `downgrade()`.
117
115
  - **Hand-edited operations with stubbed `downgrade()`.** Server defaults, CHECK constraints, expression indexes — Alembic can't always infer these. When you fill in `upgrade()`, fill in `downgrade()` too.
118
116
  - **Concurrent `make migrate` (`upgrade heads`) from multiple processes.** The `alembic_version` table isn't race-safe across all backends. Run upgrades from one place (a release pipeline step), not from the booting app.
@@ -0,0 +1,25 @@
1
+ # --- docker --------------------------------------------------------------
2
+ .PHONY: docker-build docker-up docker-down
3
+
4
+ docker-build: ## Build the app image
5
+ docker compose build
6
+
7
+ docker-up: ## Run the full stack in containers
8
+ @test -f .env || { cp .env.example .env; \
9
+ echo "Created .env from .env.example: set POSTGRES_PASSWORD (Postgres apps) and the"; \
10
+ echo "commented SM_* secrets in .env; production containers refuse to boot without them."; }
11
+ @# compose interpolates POSTGRES_PASSWORD raw into the SM_DATABASE_URL, so it must be URL-safe.
12
+ @# The value is read the way compose reads it: last definition wins, CRLF, an inline
13
+ @# trailing comment and surrounding quotes are not part of the password.
14
+ @pw=$$(sed -n 's/^POSTGRES_PASSWORD=//p' .env | tail -n 1 | tr -d '\r' \
15
+ | sed -e 's/[[:space:]]#.*$$//' -e 's/[[:space:]]*$$//' -e 's/^"\(.*\)"$$/\1/' -e "s/^'\(.*\)'$$/\1/"); \
16
+ if printf '%s' "$$pw" | grep -q '[^A-Za-z0-9._~-]'; then \
17
+ echo "POSTGRES_PASSWORD in .env must be URL-safe (letters, digits, - _ . ~ only):" >&2; \
18
+ echo "compose puts it into SM_DATABASE_URL unencoded. Pick another password." >&2; \
19
+ exit 1; \
20
+ fi
21
+ docker compose up --build
22
+
23
+ docker-down: ## Stop the compose stack
24
+ docker compose down
25
+ # --- end docker ----------------------------------------------------------
@@ -4,9 +4,11 @@ services:
4
4
  environment:
5
5
  POSTGRES_DB: "{{PG_DB}}"
6
6
  POSTGRES_USER: postgres
7
- POSTGRES_PASSWORD: postgres
7
+ POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}"
8
8
  ports:
9
- - "5432:5432"
9
+ # Loopback only: reachable as a local dev database, never from the
10
+ # network. The app reaches it over the compose network (GH #444).
11
+ - "127.0.0.1:5432:5432"
10
12
  volumes:
11
13
  - pgdata:/var/lib/postgresql/data
12
14
  healthcheck:
@@ -26,7 +28,7 @@ services:
26
28
  # Containers serve the built bundle; development mode would emit
27
29
  # asset tags pointing at the (absent) Vite dev server.
28
30
  SM_ENVIRONMENT: production
29
- SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}}
31
+ SM_DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/{{PG_DB}}"
30
32
  {{APP_EXTRA_ENV}} ports:
31
33
  - "8000:8000"
32
34
  depends_on: