simple-module-cli 0.0.11__tar.gz → 0.0.13__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 (92) hide show
  1. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/PKG-INFO +11 -11
  2. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/README.md +9 -9
  3. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/pyproject.toml +3 -3
  4. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/app_project.py +39 -21
  5. simple_module_cli-0.0.13/simple_module_cli/case.py +79 -0
  6. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/cli.py +7 -7
  7. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/new.py +42 -5
  8. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/package_update.py +1 -1
  9. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/plugins.py +3 -3
  10. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/recipes.py +2 -2
  11. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/scaffolding.py +77 -21
  12. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/README.md +12 -12
  13. simple_module_cli-0.0.13/simple_module_cli/skills/simple-module-cli/SKILL.md +170 -0
  14. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-creating/SKILL.md +2 -2
  15. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +2 -2
  16. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-migrations/SKILL.md +1 -1
  17. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-testing/SKILL.md +1 -1
  18. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills_cmd.py +4 -4
  19. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/package.json.tpl +1 -1
  20. simple_module_cli-0.0.13/simple_module_cli/templates/host/client_app/pages/Landing.tsx +78 -0
  21. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/pages.ts +1 -1
  22. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/vite.config.ts +85 -24
  23. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/main.py +4 -1
  24. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/migrations/env.py +11 -1
  25. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/pyproject.toml.tpl +1 -1
  26. simple_module_cli-0.0.13/simple_module_cli/templates/host/routes.py +28 -0
  27. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -1
  28. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/README.md.tpl +2 -0
  29. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/package.json.tpl +1 -1
  30. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/pyproject.toml.tpl +1 -1
  31. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/wizard.py +1 -1
  32. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_build_packaging.py +2 -2
  33. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_cli_new.py +2 -1
  34. simple_module_cli-0.0.13/tests/test_cli_new_dest_tolerance.py +96 -0
  35. simple_module_cli-0.0.13/tests/test_cli_new_regressions.py +272 -0
  36. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_cli_package_update.py +1 -1
  37. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_cli_wizard.py +1 -1
  38. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_scaffolding_host.py +4 -2
  39. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_scaffolding_module.py +2 -2
  40. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_skills_cmd.py +1 -1
  41. simple_module_cli-0.0.11/simple_module_cli/case.py +0 -38
  42. simple_module_cli-0.0.11/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -170
  43. simple_module_cli-0.0.11/tests/test_cli_new_regressions.py +0 -61
  44. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/.gitignore +0 -0
  45. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/LICENSE +0 -0
  46. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/__init__.py +0 -0
  47. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/_env.py +0 -0
  48. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/catalog.py +0 -0
  49. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  50. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  51. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  52. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  53. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  54. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/.env.example +0 -0
  55. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/.gitignore +0 -0
  56. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/Makefile +0 -0
  57. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/README.md.tpl +0 -0
  58. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  59. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
  60. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -0
  61. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  62. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
  63. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/alembic.ini +0 -0
  64. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  65. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  66. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  67. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  68. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  69. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  70. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  71. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/templates/index.html +0 -0
  72. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  73. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  74. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/.gitignore +0 -0
  75. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/README.md.tpl +0 -0
  76. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  77. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  78. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  79. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
  80. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  81. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  82. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/package.json.tpl +0 -0
  83. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  84. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  85. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  86. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/.env.example +0 -0
  87. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/.gitignore +0 -0
  88. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/Makefile +0 -0
  89. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_cli_catalog.py +0 -0
  90. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_cli_recipes.py +0 -0
  91. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_no_framework_deps.py +0 -0
  92. {simple_module_cli-0.0.11 → simple_module_cli-0.0.13}/tests/test_plugin_discovery.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_cli
3
- Version: 0.0.11
4
- Summary: Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host.
3
+ Version: 0.0.13
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
7
7
  Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
@@ -34,7 +34,7 @@ pip install simple_module_cli
34
34
  # or, to keep the CLI in its own venv:
35
35
  pipx install simple_module_cli
36
36
  # or, to run it without installing:
37
- uvx --from simple_module_cli sm new my-app
37
+ uvx --from simple_module_cli smpy new my-app
38
38
  ```
39
39
 
40
40
  The package depends only on `typer` and `tomlkit` — installing it does **not** pull in FastAPI, SQLModel, or any other framework runtime.
@@ -42,21 +42,21 @@ The package depends only on `typer` and `tomlkit` — installing it does **not**
42
42
  ## Usage
43
43
 
44
44
  ```bash
45
- sm new my-app # interactive wizard
46
- sm new my-app --yes --preset full # all built-in modules + background jobs
47
- sm create-module my_feature # scaffold a publishable module package
48
- sm create-host bare-host # scaffold a bare host (no opinionated wiring)
45
+ smpy new my-app # interactive wizard
46
+ smpy new my-app --yes --preset full # all built-in modules + background jobs
47
+ smpy create-module my_feature # scaffold a publishable module package
48
+ smpy create-host bare-host # scaffold a bare host (no opinionated wiring)
49
49
  ```
50
50
 
51
- Built-in commands: `sm new`, `sm create-host`, `sm create-module`.
51
+ Built-in commands: `smpy new`, `smpy create-host`, `smpy create-module`.
52
52
 
53
53
  When other framework packages are installed, they contribute additional subcommands via the `simple_module_cli.cli_plugins` entry-point group:
54
54
 
55
55
  | Package | Commands |
56
56
  |---|---|
57
- | `simple_module_hosting` | `sm host gen-pages`, `sm host sync-js-deps` |
58
- | `simple_module_users` | `sm users create-admin` |
59
- | `simple_module_settings` | `sm settings import-from-env` |
57
+ | `simple_module_hosting` | `smpy host gen-pages`, `smpy host sync-js-deps` |
58
+ | `simple_module_users` | `smpy users create-admin` |
59
+ | `simple_module_settings` | `smpy settings import-from-env` |
60
60
 
61
61
  ## License
62
62
 
@@ -9,7 +9,7 @@ pip install simple_module_cli
9
9
  # or, to keep the CLI in its own venv:
10
10
  pipx install simple_module_cli
11
11
  # or, to run it without installing:
12
- uvx --from simple_module_cli sm new my-app
12
+ uvx --from simple_module_cli smpy new my-app
13
13
  ```
14
14
 
15
15
  The package depends only on `typer` and `tomlkit` — installing it does **not** pull in FastAPI, SQLModel, or any other framework runtime.
@@ -17,21 +17,21 @@ The package depends only on `typer` and `tomlkit` — installing it does **not**
17
17
  ## Usage
18
18
 
19
19
  ```bash
20
- sm new my-app # interactive wizard
21
- sm new my-app --yes --preset full # all built-in modules + background jobs
22
- sm create-module my_feature # scaffold a publishable module package
23
- sm create-host bare-host # scaffold a bare host (no opinionated wiring)
20
+ smpy new my-app # interactive wizard
21
+ smpy new my-app --yes --preset full # all built-in modules + background jobs
22
+ smpy create-module my_feature # scaffold a publishable module package
23
+ smpy create-host bare-host # scaffold a bare host (no opinionated wiring)
24
24
  ```
25
25
 
26
- Built-in commands: `sm new`, `sm create-host`, `sm create-module`.
26
+ Built-in commands: `smpy new`, `smpy create-host`, `smpy create-module`.
27
27
 
28
28
  When other framework packages are installed, they contribute additional subcommands via the `simple_module_cli.cli_plugins` entry-point group:
29
29
 
30
30
  | Package | Commands |
31
31
  |---|---|
32
- | `simple_module_hosting` | `sm host gen-pages`, `sm host sync-js-deps` |
33
- | `simple_module_users` | `sm users create-admin` |
34
- | `simple_module_settings` | `sm settings import-from-env` |
32
+ | `simple_module_hosting` | `smpy host gen-pages`, `smpy host sync-js-deps` |
33
+ | `simple_module_users` | `smpy users create-admin` |
34
+ | `simple_module_settings` | `smpy settings import-from-env` |
35
35
 
36
36
  ## License
37
37
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "simple_module_cli"
3
- version = "0.0.11"
4
- description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host."
3
+ version = "0.0.13"
4
+ description = "Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -25,7 +25,7 @@ dependencies = [
25
25
  ]
26
26
 
27
27
  [project.scripts]
28
- sm = "simple_module_cli.cli:main"
28
+ smpy = "simple_module_cli.cli:main"
29
29
  simple-module = "simple_module_cli.cli:main"
30
30
 
31
31
  [project.urls]
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import json as _json
17
17
  import secrets as _secrets
18
+ import shutil as _shutil
18
19
  from collections.abc import Sequence
19
20
  from importlib.metadata import PackageNotFoundError
20
21
  from importlib.metadata import version as _pkg_version
@@ -26,6 +27,7 @@ from simple_module_cli.case import to_kebab_case, to_pascal_case
26
27
  from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
27
28
  from simple_module_cli.recipes import RECIPES, ScaffoldCtx
28
29
  from simple_module_cli.scaffolding import (
30
+ SAFE_PRESERVED_NAMES,
29
31
  _module_to_pypi_name,
30
32
  create_host,
31
33
  create_module,
@@ -55,7 +57,14 @@ def _resolve_framework_version() -> str:
55
57
 
56
58
  _FRAMEWORK_VERSION = _resolve_framework_version()
57
59
 
58
- _APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"]
60
+ # Pin ``simple_module_cli`` as a dev dep so ``uv run smpy`` resolves to the
61
+ # project venv. The global ``uv tool`` install runs in its own isolated venv
62
+ # that can't see the project's plugin entry points (issue #134).
63
+ _APP_PY_DEV_DEPS = [
64
+ f"simple_module_test=={_FRAMEWORK_VERSION}",
65
+ f"simple_module_cli=={_FRAMEWORK_VERSION}",
66
+ "pytest>=8.0",
67
+ ]
59
68
 
60
69
  _APP_NPM_DEPS = {
61
70
  "@simple-module-py/ui": _FRAMEWORK_VERSION,
@@ -89,35 +98,39 @@ def create_app_project(
89
98
  tenancy: bool = False,
90
99
  selected: Sequence[str] | None = None,
91
100
  flat: bool = False,
92
- ) -> None:
101
+ ) -> tuple[Path, list[Path]]:
93
102
  """Greenfield ``simple-module new`` scaffold.
94
103
 
95
- In workspace mode (the default), lays down a uv + npm workspace at
96
- ``target/`` with the host under ``target/host/`` and a sample module
97
- under ``target/modules/hello/``. In flat mode (``flat=True``), keeps
98
- the legacy single-host layout: host files at ``target/`` with no
99
- ``modules/`` directory or workspace plumbing.
104
+ Workspace mode (default) lays down a uv + npm workspace at ``target/``
105
+ with the host under ``target/host/`` and a sample module under
106
+ ``target/modules/hello/``. Flat mode keeps the legacy single-host layout.
107
+ Tolerates ``SAFE_PRESERVED_NAMES`` at ``target`` (leftovers from
108
+ ``git init`` / ``gh repo create`` / IDE setup); other pre-existing
109
+ entries raise ``FileExistsError``.
100
110
 
101
- Generates a secret, picks a DB URL, rewrites the host's
102
- ``pyproject.toml`` / the relevant ``package.json`` to pin exact
103
- framework versions, and applies any matching post-scaffold recipes
104
- (e.g. the ``background_tasks`` recipe drops a Celery worker stack).
111
+ Returns ``(host_dir, preserved)`` the host directory (``target`` in
112
+ flat mode, ``target/host`` in workspace mode) and the paths whose
113
+ scaffold copy was skipped because the user already had one.
105
114
  """
106
- if target.exists() and any(target.iterdir()):
107
- raise FileExistsError(
108
- f"Destination {target} already exists and is non-empty; "
109
- "choose a new path or remove its contents first."
110
- )
111
-
112
115
  chosen = list(selected) if selected is not None else list(PRESETS["standard"])
113
116
  resolved, _added = expand_deps(chosen)
114
117
 
115
118
  display_names = [to_pascal_case(CATALOG[m].display) for m in resolved]
116
119
  host_dir = target if flat else target / "host"
120
+ preserved: list[Path] = []
117
121
  if not flat:
118
- target.mkdir(parents=True, exist_ok=True)
119
- create_workspace(target, name=name)
120
- create_host(host_dir, name=name, modules=display_names, framework_version=_FRAMEWORK_VERSION)
122
+ preserved.extend(
123
+ create_workspace(target, name=name, preserve_existing=SAFE_PRESERVED_NAMES)
124
+ )
125
+ preserved.extend(
126
+ create_host(
127
+ host_dir,
128
+ name=name,
129
+ modules=display_names,
130
+ framework_version=_FRAMEWORK_VERSION,
131
+ preserve_existing=SAFE_PRESERVED_NAMES if flat else frozenset(),
132
+ )
133
+ )
121
134
  if not flat:
122
135
  _strip_workspace_owned_files(host_dir)
123
136
 
@@ -169,6 +182,8 @@ def create_app_project(
169
182
  if recipe_key is not None and recipe_key in RECIPES:
170
183
  RECIPES[recipe_key].apply(target, ctx)
171
184
 
185
+ return host_dir, preserved
186
+
172
187
 
173
188
  def _strip_workspace_owned_files(host_dir: Path) -> None:
174
189
  """Drop host copies of files the workspace root owns in workspace mode."""
@@ -181,6 +196,9 @@ def _scaffold_sample_module(target: Path) -> None:
181
196
  if sample_dest.exists():
182
197
  return
183
198
  create_module(sample_dest, name=_SAMPLE_MODULE_NAME)
199
+ # GitHub only reads workflows from the repo root, so the template's
200
+ # .github/ is dead inside a workspace.
201
+ _shutil.rmtree(sample_dest / ".github")
184
202
  _pin_sample_module_deps(sample_dest)
185
203
  _seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist")
186
204
 
@@ -195,7 +213,7 @@ def _seed_static_dist_placeholder(static_dist: Path) -> None:
195
213
  def _pin_sample_module_deps(sample_dest: Path) -> None:
196
214
  """Replace the module template's future-API range pins with exact pins.
197
215
 
198
- The shared ``sm create-module`` template ships ``>=1.0,<2.0`` against the
216
+ The shared ``smpy create-module`` template ships ``>=1.0,<2.0`` against the
199
217
  framework's eventual stable line, but the workspace-bundled sample has to
200
218
  resolve against whatever the framework version actually is today (``==X``
201
219
  in pre-1.0). Without rewriting, ``uv sync`` can't satisfy the workspace.
@@ -0,0 +1,79 @@
1
+ """Identifier case-conversion helpers used by every scaffolder.
2
+
3
+ Module/host names are accepted in any case style and normalized to the
4
+ three forms the templates need: snake_case (Python package + entry-point
5
+ key), kebab-case (PyPI slug), and PascalCase (display name in Meta).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ __all__ = [
13
+ "InvalidScaffoldNameError",
14
+ "to_kebab_case",
15
+ "to_pascal_case",
16
+ "to_snake_case",
17
+ "validate_scaffold_name",
18
+ ]
19
+
20
+
21
+ class InvalidScaffoldNameError(ValueError):
22
+ """Raised when a user-supplied scaffold name can't be canonicalized.
23
+
24
+ Names that mix case, lead with a digit, contain spaces or non
25
+ ``[a-z0-9_-]`` characters, or mix ``_`` and ``-`` separators within
26
+ the same identifier are ambiguous: the scaffolder would have to guess
27
+ a canonical form, and the directory name would diverge from the
28
+ READMEs that reference it. Reject up front instead.
29
+ """
30
+
31
+
32
+ def to_snake_case(name: str) -> str:
33
+ """'MyFeature' / 'my-feature' / 'My Feature' / 'URLPath' -> 'my_feature' / 'url_path'.
34
+
35
+ Handles acronyms by treating ``Acronym|Word`` and ``word|Capital`` as
36
+ boundaries: ``URLPath`` -> ``url_path``, ``APIClient`` -> ``api_client``,
37
+ ``HTTPServer2`` -> ``http_server2``. The single-pass ``(?=[A-Z])`` form
38
+ that preceded this would emit ``u_r_l_path`` and propagate the typo
39
+ into the PyPI slug + display name.
40
+ """
41
+ s = re.sub(r"[\s\-]+", "_", name)
42
+ s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", s)
43
+ s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
44
+ return s.lower()
45
+
46
+
47
+ def to_kebab_case(name: str) -> str:
48
+ """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug)."""
49
+ return to_snake_case(name).replace("_", "-")
50
+
51
+
52
+ def to_pascal_case(name: str) -> str:
53
+ """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta)."""
54
+ snake = to_snake_case(name)
55
+ return "".join(part.capitalize() for part in snake.split("_") if part)
56
+
57
+
58
+ _VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$")
59
+
60
+
61
+ def validate_scaffold_name(name: str) -> str:
62
+ """Reject ambiguous host/module names; return the canonical display form.
63
+
64
+ A valid name is lowercase alphanumerics with at most one separator
65
+ style — either ``_`` or ``-``, not both. Examples:
66
+
67
+ * ``simple_module_chat`` -> ``simple_module_chat``
68
+ * ``simple-module-chat`` -> ``simple-module-chat``
69
+ * ``MyApp`` -> rejected (mixed case is ambiguous: ``my-app`` or ``my_app``?)
70
+ * ``1chat`` -> rejected (must start with a letter)
71
+ * ``foo_bar-baz`` -> rejected (mixed separators leave no canonical form)
72
+ """
73
+ if not name or not _VALID_NAME_RE.match(name) or ("_" in name and "-" in name):
74
+ raise InvalidScaffoldNameError(
75
+ f"{name!r} is not a valid scaffold name. Use lowercase letters and "
76
+ "digits with at most one separator style (all '_' or all '-'), "
77
+ "starting with a letter. e.g. 'my_app', 'my-app', or 'myapp'."
78
+ )
79
+ return name
@@ -1,13 +1,13 @@
1
- """Root `sm` Typer app — scaffolders + plugin mount.
1
+ """Root `smpy` Typer app — scaffolders + plugin mount.
2
2
 
3
3
  Built-in commands:
4
- sm new
5
- sm create-host
6
- sm create-module
7
- sm skills add / list / update
4
+ smpy new
5
+ smpy create-host
6
+ smpy create-module
7
+ smpy skills add / list / update
8
8
 
9
9
  Plugins discovered via the ``simple_module_cli.cli_plugins`` entry-point
10
- group are mounted as named subgroups (e.g. ``sm host gen-pages``).
10
+ group are mounted as named subgroups (e.g. ``smpy host gen-pages``).
11
11
  """
12
12
 
13
13
  from __future__ import annotations
@@ -101,7 +101,7 @@ discover_and_mount(app)
101
101
 
102
102
 
103
103
  def main() -> None:
104
- """Entry point for the `sm` console script."""
104
+ """Entry point for the `smpy` console script."""
105
105
  app()
106
106
 
107
107
 
@@ -1,4 +1,4 @@
1
- """``sm new`` Typer command — flag-driven or interactive scaffolder."""
1
+ """``smpy new`` Typer command — flag-driven or interactive scaffolder."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -11,11 +11,14 @@ from typing import Annotated
11
11
  import typer
12
12
 
13
13
  from simple_module_cli.app_project import create_app_project
14
+ from simple_module_cli.case import InvalidScaffoldNameError, to_kebab_case, validate_scaffold_name
14
15
  from simple_module_cli.catalog import PRESETS, expand_deps
15
16
  from simple_module_cli.wizard import run_wizard
16
17
 
17
18
  __all__ = ["new_project"]
18
19
 
20
+ _ALEMBIC = ("uv", "run", "alembic")
21
+
19
22
 
20
23
  class Db(StrEnum):
21
24
  sqlite = "sqlite"
@@ -61,7 +64,7 @@ def new_project(
61
64
  bool,
62
65
  typer.Option(
63
66
  "--no-install",
64
- help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.",
67
+ help=("Skip 'uv sync' / 'npm install' / initial alembic migration after scaffolding."),
65
68
  ),
66
69
  ] = False,
67
70
  flat: Annotated[
@@ -76,6 +79,14 @@ def new_project(
76
79
  ] = False,
77
80
  ) -> None:
78
81
  """Scaffold a new SimpleModule app, optionally with background jobs."""
82
+ try:
83
+ validate_scaffold_name(name)
84
+ except InvalidScaffoldNameError as exc:
85
+ typer.echo(f"ERROR: {exc}", err=True)
86
+ raise typer.Exit(code=1) from exc
87
+ pypi_name = to_kebab_case(name)
88
+ if pypi_name != name:
89
+ typer.echo(f"Normalizing PyPI name to {pypi_name!r}.")
79
90
  target = dest or Path.cwd() / name
80
91
  extra_list = [m.strip() for m in extra.split(",") if m.strip()]
81
92
  flag_driven = preset is not None or bool(extra_list)
@@ -100,7 +111,7 @@ def new_project(
100
111
  raise typer.Exit(code=1) from None
101
112
 
102
113
  try:
103
- create_app_project(
114
+ host_dir, preserved = create_app_project(
104
115
  target,
105
116
  name=name,
106
117
  db=db_final,
@@ -114,12 +125,21 @@ def new_project(
114
125
 
115
126
  typer.echo(f"Created app '{name}' at {target}")
116
127
  typer.echo(f"Modules: {', '.join(resolved)}")
128
+ if preserved:
129
+ typer.echo(
130
+ "\nPreserved existing files (scaffold's versions were skipped — "
131
+ "merge by hand if you want their contents):"
132
+ )
133
+ for path in preserved:
134
+ rel = path.relative_to(target) if path.is_relative_to(target) else path
135
+ typer.echo(f" {rel}")
117
136
  typer.echo("\nNext steps:")
118
137
  typer.echo(f" cd {target}")
119
138
  if no_install:
120
139
  typer.echo(" uv sync")
121
140
  typer.echo(" npm install")
122
- typer.echo(" alembic upgrade head")
141
+ typer.echo(' make migration msg="initial schema"')
142
+ typer.echo(" make migrate")
123
143
  typer.echo(" make dev")
124
144
  if "background_tasks" in resolved:
125
145
  typer.echo(" docker compose up -d redis worker beat # background jobs")
@@ -143,7 +163,24 @@ def new_project(
143
163
  )
144
164
  return
145
165
 
146
- subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False)
166
+ _bootstrap_initial_migration(host_dir)
167
+ subprocess.run([*_ALEMBIC, "upgrade", "head"], cwd=host_dir, check=False)
147
168
  typer.echo("\nSetup complete. Run `make dev` in the new directory.")
148
169
  if "background_tasks" in resolved:
149
170
  typer.echo("For background jobs, also run: docker compose up -d redis worker beat")
171
+
172
+
173
+ def _bootstrap_initial_migration(host_dir: Path) -> None:
174
+ """Autogenerate the baseline migration if the scaffold ships none.
175
+
176
+ Without a real revision, ``alembic upgrade head`` is a silent no-op
177
+ against an empty schema — the bundled modules' tables never exist.
178
+ """
179
+ versions_dir = host_dir / "migrations" / "versions"
180
+ if any(p.name != "__init__.py" for p in versions_dir.glob("*.py")):
181
+ return
182
+ subprocess.run(
183
+ [*_ALEMBIC, "revision", "--autogenerate", "-m", "initial schema"],
184
+ cwd=host_dir,
185
+ check=False,
186
+ )
@@ -1,4 +1,4 @@
1
- """``sm package-update`` — bump simple_module_* deps to latest PyPI versions.
1
+ """``smpy package-update`` — bump simple_module_* deps to latest PyPI versions.
2
2
 
3
3
  Walks the project's ``pyproject.toml`` (and any ``[tool.uv.workspace]`` members),
4
4
  finds every dependency whose distribution name starts with ``simple_module_`` /
@@ -1,11 +1,11 @@
1
- """Plugin discovery for ``sm`` via the ``simple_module_cli.cli_plugins`` group.
1
+ """Plugin discovery for ``smpy`` via the ``simple_module_cli.cli_plugins`` group.
2
2
 
3
3
  Each entry-point's value (``module:attr``) must resolve to a
4
4
  :class:`typer.Typer` instance. The entry-point name becomes the
5
- subcommand namespace under ``sm`` (e.g. ``sm host gen-pages``).
5
+ subcommand namespace under ``smpy`` (e.g. ``smpy host gen-pages``).
6
6
 
7
7
  Failed loads (broken import, wrong type) print one line to stderr and
8
- are skipped — ``sm`` keeps working with whatever else loads.
8
+ are skipped — ``smpy`` keeps working with whatever else loads.
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """Per-module post-scaffold recipes.
2
2
 
3
- A recipe is invoked by ``sm new`` after the base host scaffold lands. It
3
+ A recipe is invoked by ``smpy new`` after the base host scaffold lands. It
4
4
  performs module-specific actions (write helper scripts, append Make
5
5
  targets, drop a docker-compose stack). The framework layer is kept free
6
6
  of devex concerns — recipes know about Makefiles and compose, framework
@@ -68,7 +68,7 @@ class BackgroundTasksRecipe:
68
68
  if path.exists():
69
69
  raise FileExistsError(
70
70
  f"{path} already exists — refusing to clobber. "
71
- "Remove the file or run `sm new` against an empty directory."
71
+ "Remove the file or run `smpy new` against an empty directory."
72
72
  )
73
73
 
74
74
  run_worker_dest.parent.mkdir(parents=True, exist_ok=True)
@@ -21,15 +21,35 @@ import shutil
21
21
  from collections.abc import Mapping, Sequence
22
22
  from pathlib import Path
23
23
 
24
- from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case
25
-
26
- __all__ = ["create_host", "create_module", "create_workspace"]
24
+ from simple_module_cli.case import (
25
+ to_kebab_case,
26
+ to_pascal_case,
27
+ to_snake_case,
28
+ validate_scaffold_name,
29
+ )
30
+
31
+ __all__ = [
32
+ "SAFE_PRESERVED_NAMES",
33
+ "create_host",
34
+ "create_module",
35
+ "create_workspace",
36
+ ]
27
37
 
28
38
  logger = logging.getLogger(__name__)
29
39
 
30
40
  _TEMPLATES_PACKAGE = "simple_module_cli.templates"
31
41
  _PACKAGE_PATH_TOKEN = "__PACKAGE__"
32
42
 
43
+ # Pre-existing entries we tolerate at a scaffold target — typical leftovers
44
+ # from ``git init`` / ``gh repo create`` / IDE setup.
45
+ SAFE_PRESERVED_NAMES = frozenset(
46
+ {".git", ".gitignore", ".gitattributes", ".editorconfig", ".DS_Store"}
47
+ | {".claude", ".vscode", ".idea"}
48
+ | {"README", "README.md", "README.rst"}
49
+ | {"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"}
50
+ | {"CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md"}
51
+ )
52
+
33
53
 
34
54
  def _module_to_pypi_name(name: str) -> str:
35
55
  return f"simple_module_{name.lower()}"
@@ -45,12 +65,21 @@ def _iter_template_files(template_root: Path):
45
65
  yield path
46
66
 
47
67
 
48
- def _require_empty_dest(dest: Path) -> None:
49
- if dest.exists() and any(dest.iterdir()):
50
- raise FileExistsError(
51
- f"Destination {dest} already exists and is non-empty. "
52
- "Choose a new path or remove the contents first."
53
- )
68
+ def _require_empty_dest(dest: Path, *, preserve_existing: frozenset[str] = frozenset()) -> None:
69
+ """Refuse a non-empty destination unless every top-level entry is allowed.
70
+
71
+ ``preserve_existing`` is matched against the *name* of each top-level entry,
72
+ so callers can permit common pre-existing files (``.git``, ``README.md``,
73
+ ...) without silently overwriting unrelated user content.
74
+ """
75
+ if dest.exists():
76
+ unexpected = sorted(p.name for p in dest.iterdir() if p.name not in preserve_existing)
77
+ if unexpected:
78
+ raise FileExistsError(
79
+ f"Destination {dest} exists and contains files that would collide "
80
+ f"with the scaffold: {', '.join(unexpected)}. "
81
+ "Move them aside or choose another path."
82
+ )
54
83
  dest.mkdir(parents=True, exist_ok=True)
55
84
 
56
85
 
@@ -66,13 +95,20 @@ def _apply_template_files(
66
95
  substitutions: Mapping[str, str],
67
96
  *,
68
97
  path_rewrites: Mapping[str, str] | None = None,
69
- ) -> None:
98
+ preserve_existing: frozenset[str] = frozenset(),
99
+ ) -> list[Path]:
100
+ """Write template files into ``dest``; return paths skipped to preserve the user's copy."""
101
+ preserved: list[Path] = []
70
102
  for src in _iter_template_files(src_root):
71
103
  rel_str = str(src.relative_to(src_root))
72
104
  for old, new in (path_rewrites or {}).items():
73
105
  rel_str = rel_str.replace(old, new)
74
106
  rel_str = rel_str.removesuffix(".tpl")
75
107
  target = dest / rel_str
108
+ top = Path(rel_str).parts[0] if rel_str else ""
109
+ if top in preserve_existing and target.exists():
110
+ preserved.append(target)
111
+ continue
76
112
  target.parent.mkdir(parents=True, exist_ok=True)
77
113
  if src.suffix == ".tpl":
78
114
  text = src.read_text(encoding="utf-8")
@@ -81,29 +117,41 @@ def _apply_template_files(
81
117
  target.write_text(text, encoding="utf-8")
82
118
  else:
83
119
  shutil.copy2(src, target)
120
+ return preserved
84
121
 
85
122
 
86
123
  def create_workspace(
87
124
  dest: Path,
88
125
  name: str,
89
126
  template_root: Path | None = None,
90
- ) -> Path:
91
- """Materialize the workspace-root shell at ``dest``.
127
+ *,
128
+ preserve_existing: frozenset[str] = frozenset(),
129
+ ) -> list[Path]:
130
+ """Materialize the workspace-root shell at ``dest``; return preserved paths.
92
131
 
93
132
  Lays down the top-level ``pyproject.toml`` (uv workspace), ``package.json``
94
133
  (npm workspace), ``Makefile`` (delegates to host), ``.env.example``,
95
134
  ``.gitignore``, and ``README.md``. Does NOT create the host or any
96
135
  modules — those go under ``dest/host`` and ``dest/modules/`` afterwards.
136
+
137
+ ``preserve_existing`` lists top-level entry names that may already exist
138
+ in ``dest``; the scaffold's copy is skipped and the preserved path is
139
+ included in the returned list. Other pre-existing entries raise
140
+ ``FileExistsError``.
97
141
  """
98
142
  dest = Path(dest)
99
- dest.mkdir(parents=True, exist_ok=True)
100
- _apply_template_files(
143
+ _require_empty_dest(dest, preserve_existing=preserve_existing)
144
+ preserved = _apply_template_files(
101
145
  _resolve_template_root("workspace", template_root),
102
146
  dest,
103
- {"{{HOST_NAME}}": to_kebab_case(name)},
147
+ {
148
+ "{{HOST_NAME}}": validate_scaffold_name(name),
149
+ "{{HOST_PYPI_NAME}}": to_kebab_case(name),
150
+ },
151
+ preserve_existing=preserve_existing,
104
152
  )
105
153
  logger.info("Scaffolded workspace root at %s", dest)
106
- return dest
154
+ return preserved
107
155
 
108
156
 
109
157
  def create_host(
@@ -112,23 +160,31 @@ def create_host(
112
160
  modules: Sequence[str],
113
161
  template_root: Path | None = None,
114
162
  framework_version: str = "*",
115
- ) -> Path:
163
+ *,
164
+ preserve_existing: frozenset[str] = frozenset(),
165
+ ) -> list[Path]:
166
+ """Scaffold a host project at ``dest``; return preserved pre-existing paths.
167
+
168
+ ``preserve_existing`` semantics match :func:`create_workspace`.
169
+ """
116
170
  dest = Path(dest)
117
- _require_empty_dest(dest)
171
+ _require_empty_dest(dest, preserve_existing=preserve_existing)
118
172
  module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules)
119
- _apply_template_files(
173
+ preserved = _apply_template_files(
120
174
  _resolve_template_root("host", template_root),
121
175
  dest,
122
176
  {
123
- "{{HOST_NAME}}": name,
177
+ "{{HOST_NAME}}": validate_scaffold_name(name),
178
+ "{{HOST_PYPI_NAME}}": to_kebab_case(name),
124
179
  "{{MODULE_DEPS}}": module_dep_lines,
125
180
  "{{FRAMEWORK_VERSION}}": framework_version,
126
181
  },
182
+ preserve_existing=preserve_existing,
127
183
  )
128
184
  logger.info(
129
185
  "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "<none>"
130
186
  )
131
- return dest
187
+ return preserved
132
188
 
133
189
 
134
190
  def create_module(