diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index caa51d8a..1b7547fd 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -18,15 +18,19 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from pathlib import Path +from typing import Any from simple_module_cli._env import set_env_key from simple_module_cli.case import to_kebab_case, to_pascal_case from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps from simple_module_cli.recipes import RECIPES, ScaffoldCtx -from simple_module_cli.scaffolding import create_host +from simple_module_cli.scaffolding import _module_to_pypi_name, create_host, create_module __all__ = ["create_app_project"] +_SAMPLE_MODULE_NAME = "hello" +_SAMPLE_MODULE_PKG = _module_to_pypi_name(_SAMPLE_MODULE_NAME) + def _resolve_framework_version() -> str: """Resolve the framework version to pin scaffolded apps against. @@ -69,6 +73,7 @@ def create_app_project( db: str = "sqlite", tenancy: bool = False, selected: Sequence[str] | None = None, + flat: bool = False, ) -> None: """Greenfield ``simple-module new`` scaffold. @@ -101,19 +106,26 @@ def create_app_project( env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false") env_path.write_text(env_text, encoding="utf-8") + if not flat: + _scaffold_sample_module(target) + py_deps.append(_SAMPLE_MODULE_PKG) + pyproject = target / "pyproject.toml" if pyproject.exists(): text = pyproject.read_text(encoding="utf-8") - text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS) + text = _rewrite_pyproject(text, py_deps, _APP_PY_DEV_DEPS, flat=flat) pyproject.write_text(text, encoding="utf-8") pkg_path = target / "package.json" + data: dict[str, Any] if pkg_path.exists(): data = _json.loads(pkg_path.read_text(encoding="utf-8")) else: data = {"name": to_kebab_case(name), "private": True, "type": "module"} data.setdefault("dependencies", {}).update(_APP_NPM_DEPS) data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) + if not flat: + data["workspaces"] = ["client_app", "modules/*"] pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) @@ -123,14 +135,32 @@ def create_app_project( RECIPES[recipe_key].apply(target, ctx) +def _scaffold_sample_module(target: Path) -> None: + """Give the user a place to copy when they want to add a feature module. + + The alternative is reverse-engineering one of the wheel-installed + framework modules from ``.venv/site-packages/``. + """ + sample_dest = target / "modules" / _SAMPLE_MODULE_NAME + if sample_dest.exists(): + return + create_module(sample_dest, name=_SAMPLE_MODULE_NAME) + + def _db_url(db: str, slug: str) -> str: if db == "postgres": return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" return "sqlite+aiosqlite:///./app.db" -def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str: - """Replace project.dependencies + dependency-groups.dev in a pyproject.toml.""" +def _rewrite_pyproject(text: str, deps: list[str], dev_deps: list[str], *, flat: bool) -> str: + """Replace deps + wire uv workspace based on ``flat`` mode. + + Workspace mode (``flat=False``) adds a ``[tool.uv.sources]`` entry so uv + resolves the bundled sample module from the workspace, not PyPI. Flat + mode strips the static ``[tool.uv.workspace]`` block inherited from the + template — there is no ``modules/`` tree for it to point at. + """ import tomlkit doc = tomlkit.parse(text) @@ -138,4 +168,12 @@ def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str: project["dependencies"] = list(deps) groups = doc.setdefault("dependency-groups", tomlkit.table()) groups["dev"] = list(dev_deps) + tool = doc.setdefault("tool", tomlkit.table()) + uv_table = tool.setdefault("uv", tomlkit.table()) + if flat: + if "workspace" in uv_table: + del uv_table["workspace"] + else: + sources = uv_table.setdefault("sources", tomlkit.table()) + sources[_SAMPLE_MODULE_PKG] = {"workspace": True} return tomlkit.dumps(doc) diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py index 5a7507a1..1bfdd8dc 100644 --- a/framework/cli/simple_module_cli/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -64,6 +64,16 @@ def new_project( help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", ), ] = False, + flat: Annotated[ + bool, + typer.Option( + "--flat", + help=( + "Skip the modules/ directory and sample module. Use when the host " + "will only consume published modules and never author its own." + ), + ), + ] = False, ) -> None: """Scaffold a new SimpleModule app, optionally with background jobs.""" target = dest or Path.cwd() / name @@ -90,7 +100,14 @@ def new_project( raise typer.Exit(code=1) from None try: - create_app_project(target, name=name, db=db_final, tenancy=tenancy_final, selected=resolved) + create_app_project( + target, + name=name, + db=db_final, + tenancy=tenancy_final, + selected=resolved, + flat=flat, + ) except FileExistsError as exc: typer.echo(f"ERROR: {exc}", err=True) raise typer.Exit(code=1) from exc diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index ae877e90..5fec6173 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -117,6 +117,7 @@ def create_module( "{{MODULE_NAME}}": display_name, "{{MODULE_SLUG}}": slug, "{{PACKAGE_NAME}}": package_name, + "{{PACKAGE_NAME_UPPER}}": package_name.upper(), }, path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, ) diff --git a/framework/cli/simple_module_cli/templates/host/Makefile b/framework/cli/simple_module_cli/templates/host/Makefile index ca64c1bd..6b122480 100644 --- a/framework/cli/simple_module_cli/templates/host/Makefile +++ b/framework/cli/simple_module_cli/templates/host/Makefile @@ -1,8 +1,9 @@ -.PHONY: install dev dev-api dev-ui build migrate gen-pages +.PHONY: install dev dev-api dev-ui build migrate gen-pages sync-js-deps install: uv sync cd client_app && npm install + $(MAKE) sync-js-deps dev: gen-pages @echo "Starting API and UI dev servers..." @@ -22,3 +23,6 @@ migrate: gen-pages: uv run python -m simple_module_hosting gen-pages --host-dir=client_app + +sync-js-deps: + uv run python -m simple_module_hosting sync-js-deps --host-client-app=client_app diff --git a/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl index c1282d99..884a3824 100644 --- a/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl @@ -16,3 +16,9 @@ dependencies = [ # Host is an application, not a distributable package. [tool.uv] package = false + +# Add a new module with `sm create-module `, then add +# `simple_module_` to the dependency list above and to +# [tool.uv.sources] as `{ workspace = true }`. +[tool.uv.workspace] +members = ["modules/*"] diff --git a/framework/cli/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl new file mode 100644 index 00000000..e83724a9 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl @@ -0,0 +1,14 @@ +"""{{MODULE_NAME}} module settings. + +Per-module env-var prefix is ``SM_{{PACKAGE_NAME_UPPER}}_*``. Add fields here +as the module grows; the framework wires them onto +``app.state.{{PACKAGE_NAME}}`` via ``register_settings``. +""" + +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class {{MODULE_NAME}}Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="SM_{{PACKAGE_NAME_UPPER}}_", extra="ignore") diff --git a/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl index edb2072f..c9a9c726 100644 --- a/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl @@ -7,6 +7,7 @@ dependencies = [ "simple_module_core>=1.0,<2.0", "simple_module_db>=1.0,<2.0", "simple_module_hosting>=1.0,<2.0", + "pydantic-settings>=2.0", "sqlalchemy>=2.0", ] diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index e82a966f..264fd802 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -196,6 +196,66 @@ def test_sm_new_interactive_full_preset(tmp_path: Path) -> None: assert (target / "docker-compose.yml").is_file() +def test_sm_new_default_scaffolds_sample_hello_module(tmp_path: Path) -> None: + """Default (workspace) mode lays down modules/hello/ as an authoring template.""" + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + app, + ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert (target / "modules" / "hello" / "pyproject.toml").is_file() + assert (target / "modules" / "hello" / "hello" / "module.py").is_file() + + +def test_sm_new_default_wires_workspace_in_pyproject(tmp_path: Path) -> None: + """Default mode adds [tool.uv.workspace] members + a workspace source for the sample.""" + runner = CliRunner() + target = tmp_path / "demo" + runner.invoke( + app, + ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + pyproject_text = (target / "pyproject.toml").read_text() + assert "[tool.uv.workspace]" in pyproject_text + assert 'members = ["modules/*"]' in pyproject_text + assert "simple_module_hello" in pyproject_text + # Sample module is a workspace source, not pulled from PyPI. + assert "[tool.uv.sources" in pyproject_text + assert "workspace = true" in pyproject_text + + +def test_sm_new_default_adds_npm_workspaces_field(tmp_path: Path) -> None: + """Default mode declares ``workspaces`` so vite picks up modules//.""" + runner = CliRunner() + target = tmp_path / "demo" + runner.invoke( + app, + ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + data = json.loads((target / "package.json").read_text()) + assert data.get("workspaces") == ["client_app", "modules/*"] + + +def test_sm_new_flat_skips_modules_dir(tmp_path: Path) -> None: + """``--flat`` keeps the legacy single-host layout: no modules/ tree, no sample.""" + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + app, + ["new", "demo", "--yes", "--flat", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert not (target / "modules").exists() + pyproject_text = (target / "pyproject.toml").read_text() + assert "simple_module_hello" not in pyproject_text + # No workspace plumbing pointing at a non-existent modules/ tree. + assert "[tool.uv.workspace]" not in pyproject_text + data = json.loads((target / "package.json").read_text()) + assert "workspaces" not in data + + def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None: target = tmp_path / "my-app" target.mkdir() diff --git a/modules/background_tasks/package.json b/modules/background_tasks/package.json index 6064e254..ac198a43 100644 --- a/modules/background_tasks/package.json +++ b/modules/background_tasks/package.json @@ -12,5 +12,8 @@ "devDependencies": { "@simple-module-py/tsconfig": "*" }, - "dependencies": {} + "dependencies": { + "lucide-react": "^1.8.0", + "sonner": "^2.0.0" + } } diff --git a/modules/dashboard/package.json b/modules/dashboard/package.json index 6e9d327d..415c7270 100644 --- a/modules/dashboard/package.json +++ b/modules/dashboard/package.json @@ -12,5 +12,7 @@ "devDependencies": { "@simple-module-py/tsconfig": "*" }, - "dependencies": {} + "dependencies": { + "lucide-react": "^1.8.0" + } } diff --git a/modules/feature_flags/package.json b/modules/feature_flags/package.json index 2a4209a3..5e810be9 100644 --- a/modules/feature_flags/package.json +++ b/modules/feature_flags/package.json @@ -12,5 +12,8 @@ "devDependencies": { "@simple-module-py/tsconfig": "*" }, - "dependencies": {} + "dependencies": { + "lucide-react": "^1.8.0", + "sonner": "^2.0.0" + } } diff --git a/modules/file_storage/package.json b/modules/file_storage/package.json index 5b66d583..2576406e 100644 --- a/modules/file_storage/package.json +++ b/modules/file_storage/package.json @@ -12,5 +12,8 @@ "devDependencies": { "@simple-module-py/tsconfig": "*" }, - "dependencies": {} + "dependencies": { + "lucide-react": "^1.8.0", + "sonner": "^2.0.0" + } } diff --git a/modules/permissions/package.json b/modules/permissions/package.json index 00e49ad3..5f1b2e68 100644 --- a/modules/permissions/package.json +++ b/modules/permissions/package.json @@ -12,5 +12,8 @@ "devDependencies": { "@simple-module-py/tsconfig": "*" }, - "dependencies": {} + "dependencies": { + "lucide-react": "^1.8.0", + "sonner": "^2.0.0" + } } diff --git a/modules/users/package.json b/modules/users/package.json index d1eb5bf9..8e8ec7ca 100644 --- a/modules/users/package.json +++ b/modules/users/package.json @@ -12,5 +12,8 @@ "devDependencies": { "@simple-module-py/tsconfig": "*" }, - "dependencies": {} + "dependencies": { + "lucide-react": "^1.8.0", + "sonner": "^2.0.0" + } } diff --git a/packages/ui/package.json b/packages/ui/package.json index ddc9d3b9..62f503c1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,13 +26,31 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, - "./components/ui/*": "./src/components/ui/*.tsx", - "./components/*": "./src/components/*.tsx", - "./layouts/*": "./src/layouts/*.tsx", - "./hooks/*": "./src/hooks/*.ts", - "./lib/*": "./src/lib/*.ts", - "./styles/*": "./src/styles/*", - "./*": "./src/*" + "./components/ui/*": { + "types": "./src/components/ui/*.tsx", + "default": "./src/components/ui/*.tsx" + }, + "./components/*": { + "types": "./src/components/*.tsx", + "default": "./src/components/*.tsx" + }, + "./layouts/*": { + "types": "./src/layouts/*.tsx", + "default": "./src/layouts/*.tsx" + }, + "./hooks/*": { + "types": "./src/hooks/*.ts", + "default": "./src/hooks/*.ts" + }, + "./lib/*": { + "types": "./src/lib/*.ts", + "default": "./src/lib/*.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./styles/*": "./src/styles/*" }, "files": [ "src", @@ -42,17 +60,21 @@ "access": "public" }, "peerDependencies": { - "@base-ui/react": "^1.0.0", "@inertiajs/react": "^2.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "@base-ui/react": "^1.0.0", + "@simple-module-py/i18n": "0.0.7", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^1.0.0", "embla-carousel-react": "^8.0.0", "input-otp": "^1.0.0", - "lucide-react": "*", + "lucide-react": "^1.8.0", "next-themes": "^0.4.0", - "radix-ui": "*", - "react": "^19.0.0", + "radix-ui": "^1.4.0", "react-day-picker": "^9.0.0", "react-resizable-panels": "^4.0.0", "recharts": "^3.0.0", @@ -60,9 +82,6 @@ "tailwind-merge": "^3.0.0", "vaul": "^1.0.0" }, - "dependencies": { - "@simple-module-py/i18n": "0.0.7" - }, "devDependencies": { "@simple-module-py/tsconfig": "0.0.7" }