simple-module-cli 0.0.36__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.36 → simple_module_cli-0.0.37}/.gitignore +1 -0
  2. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/PKG-INFO +1 -1
  3. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/pyproject.toml +1 -1
  4. {simple_module_cli-0.0.36 → 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.36 → simple_module_cli-0.0.37}/simple_module_cli/add_cmd.py +36 -5
  7. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/app_project.py +34 -11
  8. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/cli.py +26 -0
  9. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/docker_assets.py +3 -2
  10. {simple_module_cli-0.0.36 → 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.36 → simple_module_cli-0.0.37}/simple_module_cli/pyproject_edit.py +19 -10
  13. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-doctor/SKILL.md +3 -1
  14. simple_module_cli-0.0.37/simple_module_cli/templates/docker/Makefile.snippet +25 -0
  15. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl +5 -3
  16. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl +3 -3
  17. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl +1 -1
  18. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/.env.example +3 -2
  19. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/.gitignore +1 -3
  20. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/Makefile +21 -2
  21. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/README.md.tpl +1 -1
  22. simple_module_cli-0.0.37/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +24 -0
  23. simple_module_cli-0.0.37/simple_module_cli/templates/host/client_app/pages/Error.tsx +1 -0
  24. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/migrations/env.py +9 -0
  25. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/routes.py +1 -1
  26. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +1 -1
  27. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/.env.example +3 -2
  28. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/.gitignore +1 -3
  29. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/Makefile +19 -6
  30. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/README.md.tpl +9 -10
  31. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/pyproject.toml.tpl +3 -0
  32. simple_module_cli-0.0.37/simple_module_cli/uv_workspace.py +57 -0
  33. simple_module_cli-0.0.37/tests/test_cli_add_workspace_member.py +112 -0
  34. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_docker_assets.py +82 -2
  35. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_new.py +55 -19
  36. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_new_scaffold_layout.py +32 -0
  37. simple_module_cli-0.0.37/tests/test_cli_openapi_export.py +32 -0
  38. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_recipes.py +16 -2
  39. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_css_build.py +3 -0
  40. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_scaffolding_host.py +2 -0
  41. simple_module_cli-0.0.36/simple_module_cli/templates/docker/Makefile.snippet +0 -13
  42. simple_module_cli-0.0.36/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -26
  43. simple_module_cli-0.0.36/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -13
  44. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/LICENSE +0 -0
  45. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/README.md +0 -0
  46. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/__init__.py +0 -0
  47. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/_module_build.py +0 -0
  48. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/_module_host.py +0 -0
  49. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/_templating.py +0 -0
  50. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/case.py +0 -0
  51. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/catalog.py +0 -0
  52. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/git_source.py +0 -0
  53. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/module_cmd.py +0 -0
  54. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/package_update.py +0 -0
  55. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/pins.py +0 -0
  56. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/plugins.py +0 -0
  57. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/pypi.py +0 -0
  58. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/recipes.py +0 -0
  59. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/requirements.py +0 -0
  60. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/scaffolding.py +0 -0
  61. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/README.md +0 -0
  62. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  63. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  64. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  65. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  66. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  67. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  68. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  69. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  70. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  71. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/skills_cmd.py +0 -0
  72. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl +0 -0
  73. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/dockerignore +0 -0
  74. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/host-flat.Dockerfile +0 -0
  75. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/docker/host.Dockerfile +0 -0
  76. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  77. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/alembic.ini +0 -0
  78. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  79. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  80. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/module-assets.ts +0 -0
  81. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  82. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  83. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  84. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  85. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  86. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/vite.config.ts +0 -0
  87. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/client_app/vite.dev-url.ts +0 -0
  88. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/main.py +0 -0
  89. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  90. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  91. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  92. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/host/templates/index.html +0 -0
  93. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  94. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  95. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/.gitignore +0 -0
  96. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/README.md.tpl +0 -0
  97. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  98. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  99. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  100. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl +0 -0
  101. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/pages/Index.tsx.tpl +0 -0
  102. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  103. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  104. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/_optional/standalone/package.json.tpl +0 -0
  105. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/_optional/standalone/tsconfig.json.tpl +0 -0
  106. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/package.json.tpl +0 -0
  107. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  108. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/tests/test___PACKAGE__.py.tpl +0 -0
  109. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  110. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  111. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/update_cmd.py +0 -0
  112. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/simple_module_cli/wizard.py +0 -0
  113. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/conftest.py +0 -0
  114. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_build_packaging.py +0 -0
  115. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_case.py +0 -0
  116. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_add_cmd.py +0 -0
  117. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_add_spec_parse.py +0 -0
  118. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_catalog.py +0 -0
  119. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_git_scan.py +0 -0
  120. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_new_dest_tolerance.py +0 -0
  121. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_new_git_module.py +0 -0
  122. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_new_migration_bootstrap.py +0 -0
  123. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_new_regressions.py +0 -0
  124. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_package_update.py +0 -0
  125. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_package_update_pins.py +0 -0
  126. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_pyproject_edit.py +0 -0
  127. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_update_sources.py +0 -0
  128. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_vite_port_env.py +0 -0
  129. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_wizard.py +0 -0
  130. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_cli_wizard_git.py +0 -0
  131. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_create_module_ci_skip.py +0 -0
  132. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_env_helper.py +0 -0
  133. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_cmd_build.py +0 -0
  134. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_cmd_e2e.py +0 -0
  135. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_cmd_verify.py +0 -0
  136. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_css_assets.py +0 -0
  137. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_css_subdir_sources.py +0 -0
  138. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_npm_aliases.py +0 -0
  139. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_module_pages_manifest.py +0 -0
  140. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_no_framework_deps.py +0 -0
  141. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_plugin_discovery.py +0 -0
  142. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_scaffold_rollback.py +0 -0
  143. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_scaffolded_host_setup_wizard.py +0 -0
  144. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_scaffolding_module.py +0 -0
  145. {simple_module_cli-0.0.36 → simple_module_cli-0.0.37}/tests/test_scaffolding_module_js.py +0 -0
  146. {simple_module_cli-0.0.36 → 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.36
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.36"
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
 
@@ -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:
@@ -2,7 +2,7 @@
2
2
  redis:
3
3
  image: redis:7-alpine
4
4
  ports:
5
- - "6379:6379"
5
+ - "127.0.0.1:6379:6379"
6
6
  volumes:
7
7
  - redisdata:/data
8
8
  healthcheck:
@@ -21,7 +21,7 @@
21
21
  required: false
22
22
  environment:
23
23
  SM_ENVIRONMENT: production
24
- SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}}
24
+ SM_DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/{{PG_DB}}"
25
25
  SM_BG_TASKS_BROKER_URL: redis://redis:6379/0
26
26
  SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1
27
27
  working_dir: /app
@@ -48,7 +48,7 @@
48
48
  required: false
49
49
  environment:
50
50
  SM_ENVIRONMENT: production
51
- SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}}
51
+ SM_DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/{{PG_DB}}"
52
52
  SM_BG_TASKS_BROKER_URL: redis://redis:6379/0
53
53
  SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1
54
54
  working_dir: /app
@@ -2,7 +2,7 @@
2
2
  redis:
3
3
  image: redis:7-alpine
4
4
  ports:
5
- - "6379:6379"
5
+ - "127.0.0.1:6379:6379"
6
6
  volumes:
7
7
  - redisdata:/data
8
8
  healthcheck:
@@ -5,8 +5,9 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
5
5
  # Environment: development | production
6
6
  SM_ENVIRONMENT=development
7
7
 
8
- # Secret key for session middleware — change before deploying.
9
- SM_SECRET_KEY=change-me-in-production
8
+ # Secret key for session middleware. Leave unset to have one generated and
9
+ # stored at first boot; `smpy new` writes a generated one into .env.
10
+ # SM_SECRET_KEY=
10
11
 
11
12
  # Vite dev server URL (only used in development).
12
13
  SM_VITE_DEV_URL=http://localhost:5050
@@ -3,8 +3,6 @@ __pycache__/
3
3
  *.egg-info/
4
4
  .venv/
5
5
 
6
- uv.lock
7
-
8
6
  node_modules/
9
7
 
10
8
  .env
@@ -14,6 +12,6 @@ node_modules/
14
12
 
15
13
  static/dist/
16
14
 
17
- # Auto-generated by the host at boot / `sm gen-pages`.
15
+ # Auto-generated by the host at boot / `make gen-pages`.
18
16
  client_app/modules.manifest.json
19
17
  client_app/modules.generated.ts