simple-module-hosting 0.0.1__py3-none-any.whl

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 (65) hide show
  1. simple_module_hosting/__init__.py +7 -0
  2. simple_module_hosting/_error_handlers.py +54 -0
  3. simple_module_hosting/_hydrate_step.py +39 -0
  4. simple_module_hosting/_inertia_setup.py +73 -0
  5. simple_module_hosting/_inertia_shared.py +61 -0
  6. simple_module_hosting/_observability.py +108 -0
  7. simple_module_hosting/_phase_helpers.py +160 -0
  8. simple_module_hosting/app_builder.py +281 -0
  9. simple_module_hosting/bootstrap_settings.py +55 -0
  10. simple_module_hosting/cli.py +292 -0
  11. simple_module_hosting/health.py +79 -0
  12. simple_module_hosting/host_settings.py +33 -0
  13. simple_module_hosting/i18n_deps.py +25 -0
  14. simple_module_hosting/i18n_manifest.py +202 -0
  15. simple_module_hosting/i18n_middleware.py +95 -0
  16. simple_module_hosting/inertia_deps.py +27 -0
  17. simple_module_hosting/inertia_utils.py +31 -0
  18. simple_module_hosting/logging.py +91 -0
  19. simple_module_hosting/manifest.py +250 -0
  20. simple_module_hosting/middleware.py +272 -0
  21. simple_module_hosting/migrations.py +65 -0
  22. simple_module_hosting/permissions.py +75 -0
  23. simple_module_hosting/py.typed +0 -0
  24. simple_module_hosting/redirects.py +45 -0
  25. simple_module_hosting/scaffolding.py +294 -0
  26. simple_module_hosting/settings.py +10 -0
  27. simple_module_hosting/templates/host/.env.example +20 -0
  28. simple_module_hosting/templates/host/.gitignore +19 -0
  29. simple_module_hosting/templates/host/Makefile +24 -0
  30. simple_module_hosting/templates/host/README.md.tpl +59 -0
  31. simple_module_hosting/templates/host/alembic.ini +36 -0
  32. simple_module_hosting/templates/host/client_app/app.tsx +16 -0
  33. simple_module_hosting/templates/host/client_app/main.tsx +2 -0
  34. simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
  35. simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
  36. simple_module_hosting/templates/host/client_app/pages.ts +47 -0
  37. simple_module_hosting/templates/host/client_app/styles.css +7 -0
  38. simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
  39. simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
  40. simple_module_hosting/templates/host/main.py +27 -0
  41. simple_module_hosting/templates/host/migrations/env.py +80 -0
  42. simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
  43. simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
  44. simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
  45. simple_module_hosting/templates/host/templates/index.html +12 -0
  46. simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
  47. simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
  48. simple_module_hosting/templates/module/.gitignore +14 -0
  49. simple_module_hosting/templates/module/README.md.tpl +82 -0
  50. simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
  51. simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  52. simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  53. simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
  54. simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  55. simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
  56. simple_module_hosting/templates/module/package.json.tpl +16 -0
  57. simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
  58. simple_module_hosting/templates/module/tests/__init__.py +0 -0
  59. simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
  60. simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
  61. simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
  62. simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
  63. simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
  64. simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
  65. simple_module_hosting-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,281 @@
1
+ """Application builder — discovers modules, wires everything, returns a FastAPI app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import AsyncGenerator
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+
11
+ from fastapi import APIRouter, FastAPI
12
+ from fastapi.staticfiles import StaticFiles
13
+ from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
14
+ from simple_module_core.discovery import discover_modules, topological_sort
15
+ from simple_module_core.events import EventBus
16
+ from simple_module_core.feature_flags import FeatureFlagRegistry
17
+ from simple_module_core.health import HealthRegistry
18
+ from simple_module_core.menu import MenuRegistry
19
+ from simple_module_core.permissions import PermissionRegistry
20
+ from simple_module_core.services import Services
21
+ from simple_module_db.listeners import register_listeners
22
+ from simple_module_db.session import init_db
23
+
24
+ from simple_module_hosting._inertia_setup import setup_inertia
25
+ from simple_module_hosting._phase_helpers import (
26
+ check_settings_registration,
27
+ install_middleware,
28
+ mount_module_static_dirs,
29
+ register_exception_handlers,
30
+ )
31
+ from simple_module_hosting.health import router as health_router
32
+ from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
33
+ from simple_module_hosting.migrations import check_migrations
34
+ from simple_module_hosting.settings import Settings
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _APP_TITLE = "SimpleModule"
39
+ _APP_VERSION = "0.1.0"
40
+ _DOCS_URL = "/api/docs"
41
+ _REDOC_URL = "/api/redoc"
42
+ _STATIC_MOUNT_PATH = "/static"
43
+ _STATIC_DIR_NAME = "static"
44
+ _ENV_PROJECT_ROOT = "SM_PROJECT_ROOT"
45
+
46
+
47
+ def _resolve_project_root() -> Path:
48
+ """Return the project root directory.
49
+
50
+ Prefers the ``SM_PROJECT_ROOT`` environment variable (set by
51
+ ``host/main.py``) so the framework works even when installed from a
52
+ wheel into ``site-packages`` — in that layout the fallback walk-up
53
+ below would escape the package into ``site-packages/..`` and miss
54
+ ``host/static`` entirely.
55
+
56
+ Falls back to ``parents[3]`` for the workspace-install dev loop
57
+ (simple_module_hosting/ → hosting/ → framework/ → project root).
58
+ """
59
+ override = os.environ.get(_ENV_PROJECT_ROOT)
60
+ if override:
61
+ return Path(override)
62
+ return Path(__file__).resolve().parents[3]
63
+
64
+
65
+ _PROJECT_ROOT = _resolve_project_root()
66
+
67
+
68
+ def wire_module_routes(app: FastAPI, module) -> None:
69
+ """Attach a module's API + view routers to ``app`` using its Meta prefixes.
70
+
71
+ The single canonical implementation so ``create_app`` and the test harness
72
+ in ``simple_module_testing`` stay in lockstep if ``ModuleBase`` ever gains
73
+ a new router type.
74
+ """
75
+ api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
76
+ view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
77
+ module.register_routes(api_router, view_router)
78
+ app.include_router(api_router)
79
+ app.include_router(view_router)
80
+
81
+
82
+ def create_app(settings: Settings | None = None) -> FastAPI:
83
+ """Build and configure the full FastAPI application.
84
+
85
+ Boot sequence:
86
+ 1. Load settings & discover modules
87
+ 2. Run diagnostics (dev only)
88
+ 3. Create FastAPI app & store framework state
89
+ 4. Module settings (register_settings)
90
+ 5. Module registrations (menu, permissions, flags, events, health)
91
+ 6. Initialize database
92
+ 7. Inertia setup & exception handlers (register_exception_handlers)
93
+ 8. Middleware pipeline (register_middleware)
94
+ 9. Routes (register_routes), health endpoints, static files
95
+ """
96
+ settings = settings or Settings()
97
+
98
+ # ── Phase 1: Discover modules ──────────────────────────
99
+ # Production: any bad module (import error, missing meta, wrong base
100
+ # class) fails the boot immediately with a clear message — better than
101
+ # silently shipping a partial app. Dev keeps the lenient default.
102
+ modules = discover_modules(
103
+ enabled=settings.modules_enabled,
104
+ strict=not settings.is_development,
105
+ )
106
+ modules = topological_sort(modules)
107
+ logger.info(
108
+ "Loaded %d module(s): %s",
109
+ len(modules),
110
+ ", ".join(m.meta.name for m in modules),
111
+ )
112
+
113
+ # Build the i18n registry up front so diagnostics can validate key parity
114
+ # against host/ui locales, not just module-contributed ones.
115
+ i18n_registry, i18n_extra = build_i18n_registry(settings, modules, _PROJECT_ROOT)
116
+
117
+ # ── Phase 2: Run diagnostics (dev only) ────────────────
118
+ if settings.is_development:
119
+ diagnostics = run_diagnostics(
120
+ modules,
121
+ i18n_supported_locales=settings.i18n_supported_locales,
122
+ i18n_default_locale=settings.i18n_default_locale,
123
+ i18n_extra_sources=i18n_extra,
124
+ )
125
+ errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
126
+ if diagnostics:
127
+ print_diagnostics(diagnostics)
128
+ if errors:
129
+ raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
130
+
131
+ # Emit frontend module-pages manifest so Vite can find pages shipped
132
+ # inside pip-installed module wheels. See scaffolding.py.
133
+ try:
134
+ from simple_module_hosting.scaffolding import write_module_pages_manifest
135
+
136
+ client_app = _PROJECT_ROOT / "host" / "client_app"
137
+ if client_app.is_dir():
138
+ write_module_pages_manifest(modules, client_app)
139
+ except Exception:
140
+ logger.exception("Failed to write module pages manifest — frontend may miss pages")
141
+
142
+ emit_frontend_types(i18n_registry, _PROJECT_ROOT)
143
+
144
+ # ── Phase 3: Create FastAPI app ────────────────────────
145
+ menu_registry = MenuRegistry()
146
+ perm_registry = PermissionRegistry()
147
+ ff_registry = FeatureFlagRegistry()
148
+ event_bus = EventBus()
149
+ health_registry = HealthRegistry()
150
+
151
+ @asynccontextmanager
152
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
153
+ app.state.migration = await check_migrations(app.state.sm.db.engine)
154
+
155
+ # Hydrate all registered settings from DB before any module
156
+ # on_startup hook runs, so startup code sees DB-backed values.
157
+ # Importlib keeps plugin names out of the framework AST (SM009).
158
+ if hasattr(app.state, "settings"):
159
+ import importlib
160
+
161
+ from simple_module_hosting._hydrate_step import hydrate_all
162
+
163
+ service_cls = importlib.import_module("settings.service").SettingService
164
+ store_cls = importlib.import_module("settings.store").SettingsStore
165
+
166
+ async with app.state.sm.db.session_factory() as session:
167
+ await hydrate_all(app, store_cls(service_cls(session)))
168
+
169
+ for mod in modules:
170
+ await mod.on_startup(app)
171
+ yield
172
+ for mod in reversed(modules):
173
+ await mod.on_shutdown(app)
174
+ await app.state.sm.db.engine.dispose()
175
+
176
+ app = FastAPI(
177
+ title=_APP_TITLE,
178
+ version=_APP_VERSION,
179
+ docs_url=_DOCS_URL if settings.is_development else None,
180
+ redoc_url=_REDOC_URL if settings.is_development else None,
181
+ lifespan=lifespan,
182
+ )
183
+
184
+ # ── Phase 4: Module settings ───────────────────────────
185
+ for mod in modules:
186
+ mod.register_settings(app)
187
+
188
+ # Register host-level settings under package="host" (DB-backed). The
189
+ # Settings module must already have run register_settings (topo order
190
+ # puts it early; its meta.depends_on = [] so it's among the first).
191
+ # When the Settings module isn't enabled, there's no registry to
192
+ # register against — skip quietly.
193
+ #
194
+ # We resolve `settings.registration` via importlib rather than a plain
195
+ # `from settings.registration import ...`: the SM009 coupling check is
196
+ # AST-based and forbids any static import of a plugin package name
197
+ # from within framework/* code. Dynamic resolution keeps the framework
198
+ # AST plugin-free while still hitting the real helper at runtime.
199
+ if hasattr(app.state, "settings"):
200
+ import importlib
201
+ from dataclasses import dataclass as _dataclass
202
+
203
+ from simple_module_hosting.host_settings import HostSettings
204
+
205
+ _register_module_settings = importlib.import_module(
206
+ "settings.registration"
207
+ ).register_module_settings
208
+
209
+ @_dataclass
210
+ class _HostServices:
211
+ settings: HostSettings
212
+
213
+ _register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
214
+
215
+ if settings.is_development:
216
+ settings_diagnostics = check_settings_registration(app, modules)
217
+ if settings_diagnostics:
218
+ print_diagnostics(settings_diagnostics)
219
+
220
+ # ── Phase 5: Module registrations ──────────────────────
221
+ for mod in modules:
222
+ mod.register_menu_items(menu_registry)
223
+ mod.register_permissions(perm_registry)
224
+ mod.register_feature_flags(ff_registry)
225
+ mod.register_event_handlers(event_bus)
226
+ mod.register_health_checks(health_registry)
227
+
228
+ logger.info(
229
+ "Registered %d menu items, %d permissions, %d feature flags, %d health checks",
230
+ len(menu_registry.all_items),
231
+ len(perm_registry.all_permissions),
232
+ len(ff_registry.all_flags),
233
+ len(health_registry.all_checks),
234
+ )
235
+
236
+ # ── Phase 6: Initialize database ───────────────────────
237
+ db_state = init_db(
238
+ settings.database_url,
239
+ echo=settings.debug,
240
+ pool_size=settings.db_pool_size,
241
+ max_overflow=settings.db_max_overflow,
242
+ pool_pre_ping=settings.db_pool_pre_ping,
243
+ pool_recycle=settings.db_pool_recycle,
244
+ )
245
+ register_listeners(db_state)
246
+
247
+ # ── Phase 7: Inertia + exception handlers ──────────────
248
+ inertia_config = setup_inertia(app, settings, modules, _PROJECT_ROOT)
249
+ if inertia_config is None:
250
+ raise RuntimeError("Inertia not configured — no template directories available")
251
+ register_exception_handlers(app, modules)
252
+
253
+ # ── Phase 8: Middleware pipeline ───────────────────────
254
+ install_middleware(app, settings, modules, menu_registry, perm_registry)
255
+
256
+ # ── Phase 9: Routes, health, static files ──────────────
257
+ for mod in modules:
258
+ wire_module_routes(app, mod)
259
+
260
+ app.include_router(health_router)
261
+
262
+ static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
263
+ if static_dir.is_dir():
264
+ app.mount(_STATIC_MOUNT_PATH, StaticFiles(directory=static_dir), name=_STATIC_DIR_NAME)
265
+
266
+ mount_module_static_dirs(app, modules)
267
+
268
+ app.state.sm = Services(
269
+ settings=settings,
270
+ db=db_state,
271
+ event_bus=event_bus,
272
+ menu_registry=menu_registry,
273
+ permissions=perm_registry,
274
+ feature_flags=ff_registry,
275
+ health_registry=health_registry,
276
+ i18n_registry=i18n_registry,
277
+ inertia_config=inertia_config,
278
+ modules=tuple(modules),
279
+ )
280
+
281
+ return app
@@ -0,0 +1,55 @@
1
+ """Env-only settings read before the database is available.
2
+
3
+ Everything here is needed either to connect to the DB, sign session cookies,
4
+ or configure the Python process (logging, Vite asset URLs, module allowlist).
5
+ These values stay in ``.env`` — all other settings live in the DB.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal
11
+
12
+ from pydantic import model_validator
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+ from simple_module_core.environments import NON_PROD_ENVIRONMENTS
15
+
16
+ _PLACEHOLDER_SECRET_KEY = "change-me-in-production"
17
+
18
+
19
+ class BootstrapSettings(BaseSettings):
20
+ """Pre-DB bootstrap environment knobs."""
21
+
22
+ model_config = SettingsConfigDict(env_prefix="SM_", env_file=".env", extra="ignore")
23
+
24
+ database_url: str = "sqlite+aiosqlite:///./app.db"
25
+ db_pool_size: int = 10
26
+ db_max_overflow: int = 20
27
+ db_pool_pre_ping: bool = True
28
+ db_pool_recycle: int = 1800
29
+
30
+ environment: str = "development"
31
+ secret_key: str = _PLACEHOLDER_SECRET_KEY
32
+ vite_dev_url: str = "http://localhost:5050"
33
+ debug: bool = False
34
+
35
+ log_level: str = "INFO"
36
+ log_format: Literal["json", "text"] = "json"
37
+
38
+ modules_enabled: list[str] | None = None
39
+
40
+ @property
41
+ def is_development(self) -> bool:
42
+ return self.environment == "development"
43
+
44
+ @model_validator(mode="after")
45
+ def _forbid_placeholder_secret_in_production(self) -> BootstrapSettings:
46
+ if (
47
+ self.environment not in NON_PROD_ENVIRONMENTS
48
+ and self.secret_key == _PLACEHOLDER_SECRET_KEY
49
+ ):
50
+ raise ValueError(
51
+ f"SM_SECRET_KEY must be set to a non-default value when "
52
+ f"SM_ENVIRONMENT={self.environment!r}. Generate one with "
53
+ "`python -c 'import secrets; print(secrets.token_urlsafe(48))'`."
54
+ )
55
+ return self
@@ -0,0 +1,292 @@
1
+ """SimpleModule CLI — `sm` console script.
2
+
3
+ Currently exposes:
4
+
5
+ * ``sm create-host <name>`` — scaffold a new host directory.
6
+ * ``sm create-module <name>`` — scaffold a new module package.
7
+ * ``sm gen-pages`` — regenerate the frontend pages manifest + Tailwind CSS.
8
+ * ``sm sync-js-deps`` — install JS deps declared by installed modules.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import click
20
+ from simple_module_core import discover_modules
21
+
22
+ from simple_module_hosting.scaffolding import (
23
+ _to_kebab_case,
24
+ collect_module_js_deps,
25
+ create_module,
26
+ repo_root_from_client_app,
27
+ write_module_pages_manifest,
28
+ )
29
+ from simple_module_hosting.scaffolding import (
30
+ create_host as _create_host,
31
+ )
32
+
33
+
34
+ @click.group()
35
+ def main() -> None:
36
+ """SimpleModule developer CLI."""
37
+
38
+
39
+ @main.command("new")
40
+ @click.argument("name")
41
+ @click.option(
42
+ "--dest",
43
+ type=click.Path(file_okay=False, path_type=Path),
44
+ default=None,
45
+ help="Destination directory. Defaults to ./<name>.",
46
+ )
47
+ @click.option(
48
+ "--db",
49
+ type=click.Choice(["sqlite", "postgres"]),
50
+ default="sqlite",
51
+ show_default=True,
52
+ help="Database backend to configure in .env.example.",
53
+ )
54
+ @click.option(
55
+ "--tenancy/--no-tenancy",
56
+ default=False,
57
+ show_default=True,
58
+ help="Enable the multi-tenant middleware by default.",
59
+ )
60
+ @click.option(
61
+ "--yes",
62
+ "-y",
63
+ is_flag=True,
64
+ default=False,
65
+ help="Skip interactive prompts; accept all defaults.",
66
+ )
67
+ @click.option(
68
+ "--no-install",
69
+ is_flag=True,
70
+ default=False,
71
+ help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.",
72
+ )
73
+ def new_project(
74
+ name: str,
75
+ dest: Path | None,
76
+ db: str,
77
+ tenancy: bool,
78
+ yes: bool,
79
+ no_install: bool,
80
+ ) -> None:
81
+ """Scaffold a new SimpleModule app — pre-wired with users, dashboard, permissions."""
82
+ target = dest or Path.cwd() / name
83
+ if not yes:
84
+ db = click.prompt(
85
+ "Database backend",
86
+ default=db,
87
+ type=click.Choice(["sqlite", "postgres"]),
88
+ )
89
+ tenancy = click.confirm("Enable multi-tenancy?", default=tenancy)
90
+
91
+ from simple_module_hosting.scaffolding import create_app_project
92
+
93
+ try:
94
+ create_app_project(target, name=name, db=db, tenancy=tenancy)
95
+ except FileExistsError as exc:
96
+ click.echo(f"ERROR: {exc}", err=True)
97
+ sys.exit(1)
98
+
99
+ click.echo(f"Created app '{name}' at {target}")
100
+ click.echo("\nPre-wired modules: users, dashboard, permissions")
101
+ click.echo("\nNext steps:")
102
+ click.echo(f" cd {target}")
103
+ if no_install:
104
+ click.echo(" uv sync")
105
+ click.echo(" npm install")
106
+ click.echo(" alembic upgrade head")
107
+ click.echo(" make dev")
108
+ return
109
+
110
+ click.echo("Installing dependencies...")
111
+ for cmd in (["uv", "sync"], ["npm", "install"]):
112
+ result = subprocess.run(cmd, cwd=target, check=False)
113
+ if result.returncode != 0:
114
+ click.echo(
115
+ f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); "
116
+ f"finish setup manually.",
117
+ err=True,
118
+ )
119
+ return
120
+
121
+ subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False)
122
+ click.echo("\nSetup complete. Run `make dev` in the new directory.")
123
+
124
+
125
+ @main.command("create-host")
126
+ @click.argument("name")
127
+ @click.option(
128
+ "--dest",
129
+ type=click.Path(file_okay=False, path_type=Path),
130
+ default=None,
131
+ help="Destination directory. Defaults to ./<name>.",
132
+ )
133
+ @click.option(
134
+ "--with",
135
+ "modules",
136
+ default="",
137
+ help="Comma-separated module names to declare as deps (e.g. --with=Auth,Products).",
138
+ )
139
+ def create_host(name: str, dest: Path | None, modules: str) -> None:
140
+ """Scaffold a new SimpleModule host project at ./<NAME>."""
141
+ target = dest or Path.cwd() / name
142
+ selected = [m.strip() for m in modules.split(",") if m.strip()]
143
+
144
+ try:
145
+ _create_host(target, name=name, modules=selected)
146
+ except FileExistsError as exc:
147
+ click.echo(f"ERROR: {exc}", err=True)
148
+ sys.exit(1)
149
+
150
+ click.echo(f"Created host '{name}' at {target}")
151
+ if selected:
152
+ click.echo(f"Declared modules: {', '.join(selected)}")
153
+ click.echo("\nNext steps:")
154
+ click.echo(f" cd {target}")
155
+ click.echo(" uv sync")
156
+ click.echo(" cp .env.example .env")
157
+ click.echo(' alembic revision --autogenerate -m "initial schema"')
158
+ click.echo(" alembic upgrade head")
159
+ click.echo(" python main.py")
160
+
161
+
162
+ @main.command("create-module")
163
+ @click.argument("name")
164
+ @click.option(
165
+ "--dest",
166
+ type=click.Path(file_okay=False, path_type=Path),
167
+ default=None,
168
+ help="Destination directory. Defaults to ./simple_module_<package>.",
169
+ )
170
+ def create_module_cmd(name: str, dest: Path | None) -> None:
171
+ """Scaffold a publishable SimpleModule module package."""
172
+ slug = _to_kebab_case(name)
173
+ package = slug.replace("-", "_")
174
+ target = dest or Path.cwd() / f"simple_module_{package}"
175
+
176
+ try:
177
+ create_module(target, name=name)
178
+ except FileExistsError as exc:
179
+ click.echo(f"ERROR: {exc}", err=True)
180
+ sys.exit(1)
181
+
182
+ click.echo(f"Created module 'simple_module_{package}' at {target}")
183
+ click.echo("\nNext steps:")
184
+ click.echo(f" cd {target}")
185
+ click.echo(" uv sync --extra dev")
186
+ click.echo(" uv run pytest")
187
+
188
+
189
+ @main.command("gen-pages")
190
+ @click.option(
191
+ "--host-dir",
192
+ type=click.Path(file_okay=False, exists=True, path_type=Path),
193
+ default=None,
194
+ help="Path to the host's client_app directory. Defaults to ./client_app.",
195
+ )
196
+ def gen_pages(host_dir: Path | None) -> None:
197
+ """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}."""
198
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
199
+ output = host_dir or Path.cwd() / "client_app"
200
+ if not output.is_dir():
201
+ click.echo(f"ERROR: client_app directory not found at {output}", err=True)
202
+ sys.exit(1)
203
+
204
+ modules = discover_modules()
205
+ written = write_module_pages_manifest(modules, output)
206
+ click.echo(
207
+ f"Wrote {written['manifest'].name}, {written['generated'].name}, "
208
+ f"{written['css'].name} to {output}"
209
+ )
210
+
211
+
212
+ @main.command("sync-js-deps")
213
+ @click.option(
214
+ "--host-client-app",
215
+ type=click.Path(file_okay=False, exists=True, path_type=Path),
216
+ default=None,
217
+ help="Path to host/client_app. Defaults to ./client_app.",
218
+ )
219
+ @click.option(
220
+ "--dry-run",
221
+ is_flag=True,
222
+ default=False,
223
+ help="Print the npm install command without running it.",
224
+ )
225
+ def sync_js_deps(host_client_app: Path | None, dry_run: bool) -> None:
226
+ """Install JS deps declared by installed modules into host's node_modules.
227
+
228
+ Walks every discovered module, reads its package.json, and runs a single
229
+ ``npm install --workspace host/client_app --save=false <specs>``. Use
230
+ this after ``pip install``-ing a module wheel that declares JS deps;
231
+ in-repo modules already flow through npm workspaces and need nothing.
232
+ """
233
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
234
+
235
+ output = host_client_app or Path.cwd() / "client_app"
236
+ if not output.is_dir():
237
+ click.echo(f"ERROR: client_app directory not found at {output}", err=True)
238
+ sys.exit(1)
239
+
240
+ modules = discover_modules()
241
+ by_module = collect_module_js_deps(modules)
242
+ if not by_module:
243
+ click.echo("No module JS dependencies declared.")
244
+ return
245
+
246
+ # Flatten into a single spec list. npm's own resolver handles conflicts.
247
+ specs: list[str] = []
248
+ for mod_name in sorted(by_module):
249
+ for dep, rng in sorted(by_module[mod_name].items()):
250
+ specs.append(f"{dep}@{rng}")
251
+ # Dedupe while preserving first-seen order.
252
+ deduped: list[str] = []
253
+ seen: set[str] = set()
254
+ for spec in specs:
255
+ if spec not in seen:
256
+ seen.add(spec)
257
+ deduped.append(spec)
258
+
259
+ npm = shutil.which("npm")
260
+ if npm is None:
261
+ click.echo("ERROR: npm not found on PATH.", err=True)
262
+ sys.exit(1)
263
+
264
+ # Workspace path is relative to the repo root — derive it from output.
265
+ repo_root = repo_root_from_client_app(output)
266
+ try:
267
+ workspace = str(output.resolve().relative_to(repo_root))
268
+ except ValueError:
269
+ workspace = str(output.resolve())
270
+
271
+ cmd = [
272
+ npm,
273
+ "install",
274
+ "--workspace",
275
+ workspace,
276
+ "--save=false",
277
+ "--no-audit",
278
+ "--no-fund",
279
+ *deduped,
280
+ ]
281
+ click.echo("Installing module JS deps:")
282
+ for spec in deduped:
283
+ click.echo(f" {spec}")
284
+ if dry_run:
285
+ click.echo("(dry-run) " + " ".join(cmd))
286
+ return
287
+ result = subprocess.run(cmd, cwd=repo_root, check=False)
288
+ sys.exit(result.returncode)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()