simple-module-cli 0.0.12__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 (91) hide show
  1. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/PKG-INFO +1 -1
  2. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/pyproject.toml +1 -1
  3. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/app_project.py +38 -20
  4. simple_module_cli-0.0.13/simple_module_cli/case.py +79 -0
  5. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/new.py +41 -4
  6. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/scaffolding.py +77 -21
  7. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/package.json.tpl +1 -1
  8. simple_module_cli-0.0.13/simple_module_cli/templates/host/client_app/pages/Landing.tsx +78 -0
  9. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/vite.config.ts +85 -24
  10. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/main.py +3 -0
  11. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/migrations/env.py +11 -1
  12. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/pyproject.toml.tpl +1 -1
  13. simple_module_cli-0.0.13/simple_module_cli/templates/host/routes.py +28 -0
  14. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -1
  15. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/README.md.tpl +2 -0
  16. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/package.json.tpl +1 -1
  17. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/pyproject.toml.tpl +1 -1
  18. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_cli_new.py +1 -0
  19. simple_module_cli-0.0.13/tests/test_cli_new_dest_tolerance.py +96 -0
  20. simple_module_cli-0.0.13/tests/test_cli_new_regressions.py +272 -0
  21. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_scaffolding_host.py +2 -0
  22. simple_module_cli-0.0.12/simple_module_cli/case.py +0 -38
  23. simple_module_cli-0.0.12/tests/test_cli_new_regressions.py +0 -61
  24. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/.gitignore +0 -0
  25. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/LICENSE +0 -0
  26. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/README.md +0 -0
  27. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/__init__.py +0 -0
  28. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/_env.py +0 -0
  29. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/catalog.py +0 -0
  30. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/cli.py +0 -0
  31. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/package_update.py +0 -0
  32. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/plugins.py +0 -0
  33. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/recipes.py +0 -0
  34. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/README.md +0 -0
  35. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  36. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  37. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  38. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  39. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  40. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  41. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  42. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  43. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  44. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  45. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/skills_cmd.py +0 -0
  46. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/.env.example +0 -0
  47. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/.gitignore +0 -0
  48. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/Makefile +0 -0
  49. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/README.md.tpl +0 -0
  50. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  51. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
  52. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -0
  53. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  54. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
  55. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/alembic.ini +0 -0
  56. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  57. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  58. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  59. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  60. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  61. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  62. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  63. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  64. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/host/templates/index.html +0 -0
  65. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  66. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  67. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/.gitignore +0 -0
  68. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/README.md.tpl +0 -0
  69. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  70. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  71. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  72. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
  73. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  74. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  75. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/package.json.tpl +0 -0
  76. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  77. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  78. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  79. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/.env.example +0 -0
  80. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/.gitignore +0 -0
  81. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/templates/workspace/Makefile +0 -0
  82. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/simple_module_cli/wizard.py +0 -0
  83. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_build_packaging.py +0 -0
  84. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_cli_catalog.py +0 -0
  85. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_cli_package_update.py +0 -0
  86. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_cli_recipes.py +0 -0
  87. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_cli_wizard.py +0 -0
  88. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_no_framework_deps.py +0 -0
  89. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_plugin_discovery.py +0 -0
  90. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_scaffolding_module.py +0 -0
  91. {simple_module_cli-0.0.12 → simple_module_cli-0.0.13}/tests/test_skills_cmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_cli
3
- Version: 0.0.12
3
+ Version: 0.0.13
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.12"
3
+ version = "0.0.13"
4
4
  description = "Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -15,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
 
@@ -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
@@ -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
+ )
@@ -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(
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "{{HOST_NAME}}-client-app",
2
+ "name": "{{HOST_PYPI_NAME}}-client-app",
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "scripts": {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Landing page for the public root (`/`).
3
+ *
4
+ * Scaffolded by `smpy new` so a fresh app boots straight to a friendly
5
+ * page instead of a 404. Replace or restyle as your app's front door
6
+ * takes shape — the only contract is that something renders at "/".
7
+ */
8
+
9
+ type LandingProps = {
10
+ isAuthenticated: boolean;
11
+ };
12
+
13
+ export default function Landing({ isAuthenticated }: LandingProps) {
14
+ const primaryHref = isAuthenticated ? '/dashboard' : '/users/login';
15
+ const primaryLabel = isAuthenticated ? 'Open dashboard' : 'Sign in';
16
+
17
+ return (
18
+ <main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-8 px-6 py-16">
19
+ <header className="space-y-3">
20
+ <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
21
+ simple_module_py
22
+ </p>
23
+ <h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
24
+ Your app is up.
25
+ </h1>
26
+ <p className="text-base text-muted-foreground sm:text-lg">
27
+ This is the host's landing page — generated by{' '}
28
+ <code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-sm">smpy new</code>.
29
+ Edit{' '}
30
+ <code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-sm">
31
+ client_app/pages/Landing.tsx
32
+ </code>{' '}
33
+ to make it yours.
34
+ </p>
35
+ </header>
36
+
37
+ <div className="flex flex-wrap gap-3">
38
+ <a
39
+ href={primaryHref}
40
+ className="inline-flex items-center justify-center rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
41
+ >
42
+ {primaryLabel}
43
+ </a>
44
+ <a
45
+ href="https://github.com/antosubash/simple_module_python"
46
+ className="inline-flex items-center justify-center rounded-md border border-border bg-background px-5 py-2.5 text-sm font-medium transition-colors hover:bg-secondary"
47
+ >
48
+ Read the docs
49
+ </a>
50
+ </div>
51
+
52
+ <section className="rounded-xl border border-border bg-card p-5">
53
+ <h2 className="mb-3 text-sm font-semibold">Next steps</h2>
54
+ <ul className="space-y-2 text-sm text-muted-foreground">
55
+ <li>
56
+ Create an admin:{' '}
57
+ <code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
58
+ uv run smpy users create-admin
59
+ </code>
60
+ </li>
61
+ <li>
62
+ Scaffold a module:{' '}
63
+ <code className="rounded bg-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground">
64
+ smpy create-module orders --dest modules/orders
65
+ </code>
66
+ </li>
67
+ <li>
68
+ Sign in at{' '}
69
+ <a href="/users/login" className="font-mono text-[13px] text-primary hover:underline">
70
+ /users/login
71
+ </a>{' '}
72
+ and explore the dashboard.
73
+ </li>
74
+ </ul>
75
+ </section>
76
+ </main>
77
+ );
78
+ }