From c8d7f6d042bf6314e1bf4f51547043cb2f5a3412 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 14:50:39 +0000 Subject: [PATCH 1/8] feat(cli): scaffold sm new into a uv/npm workspace with host/ + modules/ `sm new my-app` now lays down a workspace mirroring the framework repo's own layout: a project-root pyproject.toml + package.json + Makefile that delegate to a `host/` subdir (the FastAPI app) and `modules/*` (workspace member packages, pre-seeded with a `hello` sample). Wheel-installed module .tsx files were resolving outside the host's npm tree; with everything under one npm workspace, vite finds bare imports without per-module aliasing. `--flat` keeps today's single-host layout for users who only consume published modules. Closes #117. --- .../cli/simple_module_cli/app_project.py | 193 +++++++++++++----- .../cli/simple_module_cli/scaffolding.py | 32 ++- .../host/client_app/package.json.tpl | 1 + .../templates/host/client_app/vite.config.ts | 22 +- .../templates/host/pyproject.toml.tpl | 6 - .../templates/workspace/.env.example | 19 ++ .../templates/workspace/.gitignore | 20 ++ .../templates/workspace/Makefile | 41 ++++ .../templates/workspace/README.md.tpl | 62 ++++++ .../templates/workspace/package.json.tpl | 13 ++ .../templates/workspace/pyproject.toml.tpl | 17 ++ framework/cli/tests/test_cli_new.py | 85 +++++--- 12 files changed, 419 insertions(+), 92 deletions(-) create mode 100644 framework/cli/simple_module_cli/templates/workspace/.env.example create mode 100644 framework/cli/simple_module_cli/templates/workspace/.gitignore create mode 100644 framework/cli/simple_module_cli/templates/workspace/Makefile create mode 100644 framework/cli/simple_module_cli/templates/workspace/README.md.tpl create mode 100644 framework/cli/simple_module_cli/templates/workspace/package.json.tpl create mode 100644 framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index fb85f6d4..277f7343 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -1,9 +1,10 @@ """Greenfield ``simple-module new`` scaffolding. -Wraps :func:`simple_module_hosting.scaffolding.create_host` with the -opinionated bits — module-list resolution from the CLI catalog, secret -generation, DB URL selection, ``pyproject.toml`` / ``package.json`` -rewriting, and post-scaffold recipe application. +Wraps :func:`simple_module_cli.scaffolding.create_host` (and, in +workspace mode, :func:`create_workspace`) with the opinionated bits — +module-list resolution from the CLI catalog, secret generation, DB URL +selection, ``pyproject.toml`` / ``package.json`` rewriting, and +post-scaffold recipe application. Lives in its own module to keep ``scaffolding.py`` under the per-file line cap and to make the surface area of "host scaffold" vs "app @@ -24,7 +25,12 @@ 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 _module_to_pypi_name, create_host, create_module +from simple_module_cli.scaffolding import ( + _module_to_pypi_name, + create_host, + create_module, + create_workspace, +) __all__ = ["create_app_project"] @@ -56,7 +62,7 @@ def _resolve_framework_version() -> str: "@simple-module-py/i18n": _FRAMEWORK_VERSION, "react": "^19.0.0", "react-dom": "^19.0.0", - "@inertiajs/react": "^1.0.0", + "@inertiajs/react": "^2.0.0", } _APP_NPM_DEV_DEPS = { "@simple-module-py/tsconfig": _FRAMEWORK_VERSION, @@ -65,6 +71,12 @@ def _resolve_framework_version() -> str: "vite": "^8.0.0", } +# Files the host template ships that the workspace owns at the project +# root in workspace mode. After scaffolding the host into ``host/``, we +# delete these duplicates so the workspace template's copies stay +# canonical. +_HOST_FILES_OWNED_BY_WORKSPACE = (".env.example", ".gitignore", "README.md") + def create_app_project( target: Path, @@ -77,9 +89,14 @@ def create_app_project( ) -> None: """Greenfield ``simple-module new`` scaffold. - Wraps :func:`create_host` with a chosen module list (defaults to the - ``standard`` preset), generates a secret, picks a DB URL, rewrites - the generated ``package.json`` / ``pyproject.toml`` to pin exact + In workspace mode (the default), lays down a uv + npm workspace at + ``target/`` with the host under ``target/host/`` and a sample module + under ``target/modules/hello/``. In flat mode (``flat=True``), keeps + the legacy single-host layout: host files at ``target/`` with no + ``modules/`` directory or workspace plumbing. + + Generates a secret, picks a DB URL, rewrites the host's + ``pyproject.toml`` / the relevant ``package.json`` to pin exact framework versions, and applies any matching post-scaffold recipes (e.g. the ``background_tasks`` recipe drops a Celery worker stack). """ @@ -93,40 +110,44 @@ def create_app_project( resolved, _added = expand_deps(chosen) display_names = [to_pascal_case(CATALOG[m].display) for m in resolved] - create_host(target, name=name, modules=display_names, framework_version=_FRAMEWORK_VERSION) + host_dir = target if flat else target / "host" + if not flat: + target.mkdir(parents=True, exist_ok=True) + create_workspace(target, name=name) + create_host(host_dir, name=name, modules=display_names, framework_version=_FRAMEWORK_VERSION) + if not flat: + _strip_workspace_owned_files(host_dir) py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved ] + workspace_sources: list[str] = [] + if not flat: + _scaffold_sample_module(target) + py_deps.append(_SAMPLE_MODULE_PKG) + workspace_sources.append(_SAMPLE_MODULE_PKG) + env_path = target / ".env.example" env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32)) - env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name))) + env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat)) 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 = _rewrite_pyproject(text, py_deps, _APP_PY_DEV_DEPS, flat=flat) - pyproject.write_text(text, encoding="utf-8") + host_pyproject = host_dir / "pyproject.toml" + text = host_pyproject.read_text(encoding="utf-8") + # Workspace mode needs the host's [project].name distinct from the + # workspace root's, otherwise uv refuses with "two workspace members + # are both named ...". Flat mode keeps the user's exact name. + project_name = None if flat else f"{to_kebab_case(name)}-host" + text = _rewrite_pyproject( + text, py_deps, _APP_PY_DEV_DEPS, sources=workspace_sources, project_name=project_name + ) + host_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") + if flat: + _write_flat_top_level_package_json(target, name=name) ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) for mod_name in resolved: @@ -135,45 +156,113 @@ 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. +def _strip_workspace_owned_files(host_dir: Path) -> None: + """Drop host copies of files the workspace root owns in workspace mode.""" + for relpath in _HOST_FILES_OWNED_BY_WORKSPACE: + (host_dir / relpath).unlink(missing_ok=True) - The alternative is reverse-engineering one of the wheel-installed - framework modules from ``.venv/site-packages/``. - """ + +def _scaffold_sample_module(target: Path) -> None: sample_dest = target / "modules" / _SAMPLE_MODULE_NAME if sample_dest.exists(): return create_module(sample_dest, name=_SAMPLE_MODULE_NAME) + _pin_sample_module_deps(sample_dest) + # Hatch's force-include directive resolves at build time even for + # editable installs; an empty placeholder dir keeps `uv sync` from + # failing before the user has run vite build. + static_dist = sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist" + static_dist.mkdir(parents=True, exist_ok=True) + (static_dist / ".gitkeep").touch() + + +def _pin_sample_module_deps(sample_dest: Path) -> None: + """Replace the module template's future-API range pins with exact pins. + + The shared ``sm create-module`` template ships ``>=1.0,<2.0`` against the + framework's eventual stable line, but the workspace-bundled sample has to + resolve against whatever the framework version actually is today (``==X`` + in pre-1.0). Without rewriting, ``uv sync`` can't satisfy the workspace. + """ + import tomlkit + + pyproject = sample_dest / "pyproject.toml" + doc = tomlkit.parse(pyproject.read_text(encoding="utf-8")) + project = doc["project"] + project["dependencies"] = [_pin_or_keep(dep) for dep in project.get("dependencies", [])] + optional = project.get("optional-dependencies") + if optional is not None: + for extra, deps in list(optional.items()): + optional[extra] = [_pin_or_keep(dep) for dep in deps] + pyproject.write_text(tomlkit.dumps(doc), encoding="utf-8") + + +def _pin_or_keep(dep: str) -> str: + """Pin a ``simple_module_*`` requirement to the framework version; pass through otherwise.""" + pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip() + if pkg.startswith("simple_module_") or pkg.startswith("simple-module-"): + return f"{pkg}=={_FRAMEWORK_VERSION}" + return dep -def _db_url(db: str, slug: str) -> str: +def _write_flat_top_level_package_json(target: Path, *, name: str) -> None: + """In flat mode the host template doesn't ship a top-level ``package.json``. + + Create one so ``npm install`` from the project root resolves the + framework npm deps. Workspace mode doesn't need this — the workspace + template already emits a workspaces-aware top-level package.json. + """ + 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) + pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def _db_url(db: str, slug: str, *, flat: bool) -> str: if db == "postgres": return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" - return "sqlite+aiosqlite:///./app.db" + # In workspace mode the SQLite file lives next to the host (``host/app.db``) + # so ``cd host && uvicorn`` and ``cd host && alembic ...`` agree on the path. + if flat: + return "sqlite+aiosqlite:///./app.db" + return "sqlite+aiosqlite:///./host/app.db" + +def _rewrite_pyproject( + text: str, + deps: list[str], + dev_deps: list[str], + *, + sources: Sequence[str] = (), + project_name: str | None = None, +) -> str: + """Replace deps in a host ``pyproject.toml`` and pin workspace sources. -def _rewrite_pyproject(text: str, deps: list[str], dev_deps: list[str], *, flat: bool) -> str: - """Replace deps + wire uv workspace based on ``flat`` mode. + ``sources`` lists ``simple_module_*`` packages that should resolve from + the uv workspace (``modules/*``) instead of PyPI. Emits a + ``[tool.uv.sources]`` block per entry. Empty in 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. + ``project_name`` overrides ``[project].name`` — set in workspace mode + so the host's package name differs from the workspace root's. """ import tomlkit doc = tomlkit.parse(text) project = doc.setdefault("project", tomlkit.table()) + if project_name is not None: + project["name"] = project_name 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} + if sources: + tool = doc.setdefault("tool", tomlkit.table()) + uv_table = tool.setdefault("uv", tomlkit.table()) + uv_sources = uv_table.setdefault("sources", tomlkit.table()) + for src in sources: + uv_sources[src] = {"workspace": True} return tomlkit.dumps(doc) diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index 1c1491d6..17049924 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -1,9 +1,12 @@ """Host + module scaffolding via package-data templates. +* :func:`create_workspace` materializes the project-root workspace shell + (top-level ``pyproject.toml`` / ``package.json`` / ``Makefile``) from + ``simple_module_cli/templates/workspace/``. * :func:`create_host` materializes a new host project from the templates - under ``simple_module/templates/host/``. + under ``simple_module_cli/templates/host/``. * :func:`create_module` materializes a new module package from - ``simple_module/templates/module/``. + ``simple_module_cli/templates/module/``. The frontend pages manifest + per-module JS dep discovery live in :mod:`simple_module_hosting.manifest` (those need module-discovery and @@ -20,7 +23,7 @@ from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case -__all__ = ["create_host", "create_module"] +__all__ = ["create_host", "create_module", "create_workspace"] logger = logging.getLogger(__name__) @@ -80,6 +83,29 @@ def _apply_template_files( shutil.copy2(src, target) +def create_workspace( + dest: Path, + name: str, + template_root: Path | None = None, +) -> Path: + """Materialize the workspace-root shell at ``dest``. + + Lays down the top-level ``pyproject.toml`` (uv workspace), ``package.json`` + (npm workspace), ``Makefile`` (delegates to host), ``.env.example``, + ``.gitignore``, and ``README.md``. Does NOT create the host or any + modules — those go under ``dest/host`` and ``dest/modules/`` afterwards. + """ + dest = Path(dest) + dest.mkdir(parents=True, exist_ok=True) + _apply_template_files( + _resolve_template_root("workspace", template_root), + dest, + {"{{HOST_NAME}}": to_kebab_case(name)}, + ) + logger.info("Scaffolded workspace root at %s", dest) + return dest + + def create_host( dest: Path, name: str, diff --git a/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl b/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl index b78f4500..8e32527d 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl +++ b/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl @@ -15,6 +15,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@simple-module-py/tsconfig": "{{FRAMEWORK_VERSION}}", "@tailwindcss/vite": "^4.0.0", "@types/node": "^22.0.0", "@types/react": "^19.0.0", diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index a0df7657..8f064134 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -5,7 +5,23 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig, type Plugin } from 'vite'; -const projectRoot = path.resolve(__dirname, '..'); +// Host project boundary — used by the resolver plugin below to decide +// whether an importer needs re-rooting. Always one level up from +// client_app/, regardless of layout. +const hostRoot = path.resolve(__dirname, '..'); + +// File-system serve root — the directory that holds `node_modules`. In flat +// mode that's `hostRoot`; in workspace mode npm hoists `node_modules` to the +// workspace root one level higher, so we walk up to find it. +function findNodeModulesRoot(start: string): string { + let dir = start; + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'node_modules'))) return dir; + dir = path.dirname(dir); + } + return start; +} +const fsRoot = findNodeModulesRoot(__dirname); // Load the module pages manifest written by the Python host at boot. // Each entry points at an absolute pages/ directory — typically inside a @@ -40,7 +56,7 @@ function resolveFromHost(): Plugin { resolveId(source, importer) { if (!importer) return null; if (source.startsWith('.') || source.startsWith('/')) return null; - if (importer.startsWith(projectRoot + path.sep)) return null; + if (importer.startsWith(hostRoot + path.sep)) return null; let resolved = resolveCache.get(source); if (resolved === undefined) { try { @@ -90,7 +106,7 @@ export default defineConfig({ strictPort: true, origin: 'http://localhost:5050', fs: { - allow: [projectRoot, ...moduleFsAllow], + allow: [fsRoot, ...moduleFsAllow], }, }, }); 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 884a3824..c1282d99 100644 --- a/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl @@ -16,9 +16,3 @@ 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/workspace/.env.example b/framework/cli/simple_module_cli/templates/workspace/.env.example new file mode 100644 index 00000000..dd5a9955 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/workspace/.env.example @@ -0,0 +1,19 @@ +# Database — defaults to a local SQLite file under host/. +# For PostgreSQL use: postgresql+asyncpg://user:pass@host:5432/dbname +SM_DATABASE_URL=sqlite+aiosqlite:///./host/app.db + +# Environment: development | production +SM_ENVIRONMENT=development + +# Secret key for session middleware — change before deploying. +SM_SECRET_KEY=change-me-in-production + +# Vite dev server URL (only used in development). +SM_VITE_DEV_URL=http://localhost:5173 + +# Optional: JSON array to restrict which installed modules load at boot. +# SM_MODULES_ENABLED=["Auth","Hello","Users"] + +# First-boot admin seed (optional). Only applied when the users table is empty. +# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com +# SM_USERS_BOOTSTRAP_PASSWORD=changeme diff --git a/framework/cli/simple_module_cli/templates/workspace/.gitignore b/framework/cli/simple_module_cli/templates/workspace/.gitignore new file mode 100644 index 00000000..35c7b039 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/workspace/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ + +uv.lock + +node_modules/ + +.env + +*.db +*.sqlite3 + +host/static/dist/ + +# Auto-generated by the host at boot / `sm host gen-pages`. +host/client_app/modules.manifest.json +host/client_app/modules.generated.ts +host/client_app/modules.generated.css diff --git a/framework/cli/simple_module_cli/templates/workspace/Makefile b/framework/cli/simple_module_cli/templates/workspace/Makefile new file mode 100644 index 00000000..5ad1be26 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/workspace/Makefile @@ -0,0 +1,41 @@ +.PHONY: install dev dev-api dev-ui build migrate migration gen-pages sync-module-deps kill + +install: + uv sync --all-packages + npm install + +dev: gen-pages + @echo "Starting API and UI dev servers..." + $(MAKE) -j2 dev-api dev-ui + +dev-api: + cd host && uv run uvicorn main:app --reload --port 8000 + +dev-ui: + npm run dev + +build: + npm run build + +# Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} +# from installed modules (workspace + wheel-installed). +gen-pages: + uv run --project host sm host gen-pages --host-dir=host/client_app + +# Pull JS deps shipped by wheel-installed modules into host/client_app/node_modules. +# Workspace modules under modules/* don't need this — npm hoists them automatically. +sync-module-deps: + uv run --project host sm host sync-js-deps --host-client-app=host/client_app + +migrate: + cd host && uv run alembic upgrade head + +migration: + @test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1) + cd host && uv run alembic revision --autogenerate -m "$(msg)" + +kill: + @-pkill -f "uvicorn main:app" 2>/dev/null + @-pkill -f vite 2>/dev/null + @-lsof -ti:8000,5173 | xargs kill -9 2>/dev/null + @echo "Ports 8000, 5173 freed." diff --git a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl new file mode 100644 index 00000000..84e5e7a9 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl @@ -0,0 +1,62 @@ +# {{HOST_NAME}} + +A SimpleModule application, scaffolded by `sm new`. + +## Layout + +``` +{{HOST_NAME}}/ +├── host/ # FastAPI host application (workspace member) +│ ├── main.py +│ ├── client_app/ # Inertia.js + React + Vite frontend +│ ├── alembic.ini +│ └── migrations/ +└── modules/ # Your feature modules (each is a uv + npm workspace member) + └── hello/ # Sample module — copy and rename to add your own. +``` + +## Quick start + +```bash +# Install Python + JS deps (uv workspace + npm workspace) +make install + +# Copy and customize env +cp .env.example .env + +# Apply DB migrations +make migrate + +# Run API + UI together +make dev +``` + +The API listens on http://localhost:8000 and Vite on http://localhost:5173. + +## Adding a module + +```bash +# Workspace mode — drops a new module under modules// +uv run sm create-module my-feature --dest modules/my-feature + +# Wire it into the host +# 1) Add `simple_module_my_feature` to host/pyproject.toml dependencies +# 2) Add `simple_module_my_feature = { workspace = true }` to host's +# [tool.uv.sources] (so uv resolves it from the workspace, not PyPI) +# 3) make install && make migration msg="add my-feature" && make migrate +``` + +## How the workspace fits together + +- **uv workspace** (`pyproject.toml` here): members are `host` and every + directory under `modules/*`. `uv sync --all-packages` installs them all + in editable mode against the host's venv. +- **npm workspace** (`package.json` here): `host/client_app` and every + module's frontend assets share one hoisted `node_modules`. Vite walks + up from each `.tsx` file and finds React, Inertia, and shared UI + packages without per-module aliasing. + +## Falling back to a flat layout + +If you only consume published modules (no in-repo authoring), regenerate +with `sm new --flat` to skip the `modules/` tree entirely. diff --git a/framework/cli/simple_module_cli/templates/workspace/package.json.tpl b/framework/cli/simple_module_cli/templates/workspace/package.json.tpl new file mode 100644 index 00000000..2614b280 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/workspace/package.json.tpl @@ -0,0 +1,13 @@ +{ + "name": "{{HOST_NAME}}", + "private": true, + "type": "module", + "workspaces": [ + "host/client_app", + "modules/*" + ], + "scripts": { + "dev": "npm run --workspace host/client_app dev", + "build": "npm run --workspace host/client_app build" + } +} diff --git a/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl new file mode 100644 index 00000000..f35ab0f1 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl @@ -0,0 +1,17 @@ +[project] +name = "{{HOST_NAME}}" +version = "0.1.0" +description = "SimpleModule application workspace root" +requires-python = ">=3.12" +dependencies = [] + +# Workspace root: not built or installed itself. +[tool.uv] +package = false + +# uv workspace — `host/` is the application; modules/* are workspace +# members so you can iterate on them without publishing to PyPI. Add a +# `[tool.uv.sources]` entry in host/pyproject.toml for each in-repo +# module: `simple_module_ = { workspace = true }`. +[tool.uv.workspace] +members = ["host", "modules/*"] diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 26ac2c71..5d3bd721 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -9,17 +9,6 @@ from typer.testing import CliRunner -def test_sm_new_creates_app_directory(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "my-app" - result = runner.invoke( - app, - ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - assert result.exit_code == 0, result.output - assert target.is_dir() - - def test_sm_new_generates_pyproject_with_expected_deps(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "my-app" @@ -27,7 +16,9 @@ def test_sm_new_generates_pyproject_with_expected_deps(tmp_path: Path) -> None: app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) - pyproject_text = (target / "pyproject.toml").read_text() + # Workspace mode: framework/module deps live in host/pyproject.toml, + # not the workspace-root pyproject. + pyproject_text = (target / "host" / "pyproject.toml").read_text() for required in ( "simple_module_hosting", "simple_module_users", @@ -44,7 +35,9 @@ def test_sm_new_generates_package_json_with_npm_deps(tmp_path: Path) -> None: app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) - data = json.loads((target / "package.json").read_text()) + # Workspace mode: app npm deps land in host/client_app/package.json + # (the npm workspace member), not the top-level workspace package.json. + data = json.loads((target / "host" / "client_app" / "package.json").read_text()) assert "@simple-module-py/ui" in data.get("dependencies", {}) assert "@simple-module-py/i18n" in data.get("dependencies", {}) assert "@simple-module-py/tsconfig" in data.get("devDependencies", {}) @@ -63,7 +56,7 @@ def test_sm_new_pins_client_app_simple_module_deps_to_framework_version(tmp_path app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) - data = json.loads((target / "client_app" / "package.json").read_text()) + data = json.loads((target / "host" / "client_app" / "package.json").read_text()) deps = data.get("dependencies", {}) for pkg in ("@simple-module-py/ui", "@simple-module-py/i18n"): assert deps.get(pkg) == expected, ( @@ -97,7 +90,7 @@ def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: selected=["users", "background_tasks"], ) - pyproject = (target / "pyproject.toml").read_text() + pyproject = (target / "host" / "pyproject.toml").read_text() assert "simple_module_background_tasks" in pyproject assert "simple_module_auth" in pyproject # auto-added (users requires auth) assert "simple_module_dashboard" not in pyproject @@ -127,7 +120,7 @@ def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) - target = tmp_path / "demo" create_app_project(target, name="demo", db="sqlite", tenancy=False) - pyproject = (target / "pyproject.toml").read_text() + pyproject = (target / "host" / "pyproject.toml").read_text() for required in ( "simple_module_users", "simple_module_dashboard", @@ -146,7 +139,7 @@ def test_sm_new_with_preset_full_includes_background_tasks(tmp_path: Path) -> No assert result.exit_code == 0, result.output assert (target / "scripts" / "run_worker.py").is_file() assert (target / "docker-compose.yml").is_file() - pyproject = (target / "pyproject.toml").read_text() + pyproject = (target / "host" / "pyproject.toml").read_text() assert "simple_module_background_tasks" in pyproject @@ -169,7 +162,7 @@ def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: ], ) assert result.exit_code == 0, result.output - pyproject = (target / "pyproject.toml").read_text() + pyproject = (target / "host" / "pyproject.toml").read_text() assert "simple_module_users" in pyproject assert "simple_module_background_tasks" in pyproject assert "simple_module_auth" in pyproject @@ -195,7 +188,7 @@ def test_sm_new_yes_with_no_flags_uses_standard_preset(tmp_path: Path) -> None: ["new", "demo", "--yes", "--no-install", "--dest", str(target)], ) assert result.exit_code == 0, result.output - pyproject = (target / "pyproject.toml").read_text() + pyproject = (target / "host" / "pyproject.toml").read_text() for required in ( "simple_module_users", "simple_module_dashboard", @@ -230,21 +223,55 @@ def test_sm_new_default_scaffolds_sample_hello_module(tmp_path: Path) -> None: assert (target / "modules" / "hello" / "hello" / "module.py").is_file() +def test_sm_new_default_lays_down_workspace_layout(tmp_path: Path) -> None: + """Default mode mirrors the framework repo: workspace root + host/ subdir + modules/.""" + runner = CliRunner() + target = tmp_path / "demo" + runner.invoke( + app, + ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + # Workspace root files + for relpath in ("pyproject.toml", "package.json", "Makefile", ".env.example"): + assert (target / relpath).is_file(), f"missing workspace root file: {relpath}" + # Host moves under host/ + for relpath in ( + "main.py", + "alembic.ini", + "pyproject.toml", + "client_app/package.json", + "client_app/vite.config.ts", + ): + assert (target / "host" / relpath).is_file(), f"missing host file: {relpath}" + # The host's own copies of files the workspace owns are stripped to + # avoid two stale .env.example / README.md hanging around. + assert not (target / "host" / ".env.example").exists() + assert not (target / "host" / "README.md").exists() + assert not (target / "host" / ".gitignore").exists() + + 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.""" + """Default mode declares ``host`` and ``modules/*`` as uv workspace members. + + The workspace root pyproject is bare (no app deps); the host's + pyproject carries the simple_module_* deps and a [tool.uv.sources] + entry pointing the sample at the workspace, not PyPI. + """ 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 + workspace_pyproject = (target / "pyproject.toml").read_text() + assert "[tool.uv.workspace]" in workspace_pyproject + assert 'members = ["host", "modules/*"]' in workspace_pyproject + + host_pyproject = (target / "host" / "pyproject.toml").read_text() + assert "simple_module_hello" in host_pyproject + # Sample module is resolved from the workspace, not PyPI. + assert "[tool.uv.sources" in host_pyproject + assert "workspace = true" in host_pyproject def test_sm_new_default_adds_npm_workspaces_field(tmp_path: Path) -> None: @@ -256,7 +283,9 @@ def test_sm_new_default_adds_npm_workspaces_field(tmp_path: Path) -> None: ["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/*"] + # host/client_app + every module is hoisted into one node_modules so + # vite's resolver finds bare imports without per-module aliasing. + assert data.get("workspaces") == ["host/client_app", "modules/*"] def test_sm_new_flat_skips_modules_dir(tmp_path: Path) -> None: From 98dbafd2bd44ed720e1a27dabdf4ebb218b67993 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 15:28:08 +0000 Subject: [PATCH 2/8] refactor(cli): trim duplication after simplify pass - _strip_workspace_owned_files: use unlink(missing_ok=True) instead of the exists+unlink TOCTOU pair. - Drop the dead host_pyproject.exists() check (host template always emits pyproject.toml). - _write_npm_deps: collapse the flat/workspace branches to one merge body. - Workspace Makefile: delegate migrate/migration/dev-api to host via $(MAKE) -C host so the host Makefile stays the single source of truth. Add a `migration` target on the host Makefile for symmetry with the workspace Makefile in flat mode. - Trim narrative docstrings. --- framework/cli/simple_module_cli/app_project.py | 14 +++++--------- .../cli/simple_module_cli/templates/host/Makefile | 6 +++++- .../simple_module_cli/templates/workspace/Makefile | 7 +++---- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 277f7343..61d3c58a 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -71,10 +71,8 @@ def _resolve_framework_version() -> str: "vite": "^8.0.0", } -# Files the host template ships that the workspace owns at the project -# root in workspace mode. After scaffolding the host into ``host/``, we -# delete these duplicates so the workspace template's copies stay -# canonical. +# Files the host template ships that the workspace template re-emits at +# the project root. Host copies are stripped in workspace mode. _HOST_FILES_OWNED_BY_WORKSPACE = (".env.example", ".gitignore", "README.md") @@ -226,11 +224,9 @@ def _write_flat_top_level_package_json(target: Path, *, name: str) -> None: def _db_url(db: str, slug: str, *, flat: bool) -> str: if db == "postgres": return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" - # In workspace mode the SQLite file lives next to the host (``host/app.db``) - # so ``cd host && uvicorn`` and ``cd host && alembic ...`` agree on the path. - if flat: - return "sqlite+aiosqlite:///./app.db" - return "sqlite+aiosqlite:///./host/app.db" + # Workspace mode keeps the SQLite file next to host/'s alembic.ini so + # `cd host && uvicorn` and `cd host && alembic` resolve the same path. + return "sqlite+aiosqlite:///./app.db" if flat else "sqlite+aiosqlite:///./host/app.db" def _rewrite_pyproject( diff --git a/framework/cli/simple_module_cli/templates/host/Makefile b/framework/cli/simple_module_cli/templates/host/Makefile index 6b122480..162089d4 100644 --- a/framework/cli/simple_module_cli/templates/host/Makefile +++ b/framework/cli/simple_module_cli/templates/host/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev dev-api dev-ui build migrate gen-pages sync-js-deps +.PHONY: install dev dev-api dev-ui build migrate migration gen-pages sync-js-deps install: uv sync @@ -21,6 +21,10 @@ build: migrate: uv run alembic upgrade head +migration: + @test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1) + uv run alembic revision --autogenerate -m "$(msg)" + gen-pages: uv run python -m simple_module_hosting gen-pages --host-dir=client_app diff --git a/framework/cli/simple_module_cli/templates/workspace/Makefile b/framework/cli/simple_module_cli/templates/workspace/Makefile index 5ad1be26..daadb2cd 100644 --- a/framework/cli/simple_module_cli/templates/workspace/Makefile +++ b/framework/cli/simple_module_cli/templates/workspace/Makefile @@ -9,7 +9,7 @@ dev: gen-pages $(MAKE) -j2 dev-api dev-ui dev-api: - cd host && uv run uvicorn main:app --reload --port 8000 + $(MAKE) -C host dev-api dev-ui: npm run dev @@ -28,11 +28,10 @@ sync-module-deps: uv run --project host sm host sync-js-deps --host-client-app=host/client_app migrate: - cd host && uv run alembic upgrade head + $(MAKE) -C host migrate migration: - @test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1) - cd host && uv run alembic revision --autogenerate -m "$(msg)" + $(MAKE) -C host migration msg="$(msg)" kill: @-pkill -f "uvicorn main:app" 2>/dev/null From 932232728c7f7f98a40fb49119502c59c9235f5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 16:13:24 +0000 Subject: [PATCH 3/8] fix(cli): make workspace-mode scaffold actually boot end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual smoke test of `sm new test-app && cd test-app && make install && make migrate && make dev` surfaced these blockers: 1. Workspace + host pyproject both substituted to the same `[project].name`, making uv refuse with "two workspace members are both named ...". Override host's name to `-host` in workspace mode. 2. Sample module's `>=1.0,<2.0` range pins couldn't resolve against the framework's actual 0.0.8 wheels. Rewrite simple_module_* deps in the workspace-bundled hello sample to exact pins. 3. Sample module's hatch `force-include` for `/static/dist` failed at build time because the dir doesn't exist until `vite build` runs. Drop a placeholder `.gitkeep` so `uv sync --all-packages` succeeds. 4. `@inertiajs/react: ^1.0.0` in `_APP_NPM_DEPS` peer-dep-conflicted with `@simple-module-py/ui@0.0.8` (needs ^2). Bump to ^2.0.0. 5. Vite's `server.fs.allow` was scoped to host root, but in workspace mode `node_modules` is hoisted one level higher. Walk up from client_app/ to the directory that holds node_modules. 6. Workspace Makefile delegated gen-pages / sync-module-deps to `uv run --project host sm host ...`, but `sm` isn't a host dep — only `simple_module_hosting` is. Delegate to `$(MAKE) -C host gen-pages` / `$(MAKE) -C host sync-js-deps` so the host's existing recipes apply. 7. Workspace `.env.example` and Makefile assumed Vite on 5173, but the host's vite.config.ts hard-codes 5050. Align both. 8. `make install` in workspace mode missed sync-module-deps, leaving wheel-installed modules' npm peers (lucide-react, sonner, ...) uninstalled. Add it to the install target. Also captured during the manual run, but **out of scope for this PR**: a `@vitejs/plugin-react can't detect preamble` runtime error from wheel-installed module pages (`.venv/.../users/pages/Login.tsx`). That's the long-standing #110/#115 issue — it isn't introduced by this PR and the workspace structure is the long-term path to fixing it (modules authored under `modules/*` resolve cleanly). --- .../templates/workspace/.env.example | 2 +- .../simple_module_cli/templates/workspace/Makefile | 12 +++++++----- .../templates/workspace/README.md.tpl | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/framework/cli/simple_module_cli/templates/workspace/.env.example b/framework/cli/simple_module_cli/templates/workspace/.env.example index dd5a9955..9ec3db13 100644 --- a/framework/cli/simple_module_cli/templates/workspace/.env.example +++ b/framework/cli/simple_module_cli/templates/workspace/.env.example @@ -9,7 +9,7 @@ SM_ENVIRONMENT=development SM_SECRET_KEY=change-me-in-production # Vite dev server URL (only used in development). -SM_VITE_DEV_URL=http://localhost:5173 +SM_VITE_DEV_URL=http://localhost:5050 # Optional: JSON array to restrict which installed modules load at boot. # SM_MODULES_ENABLED=["Auth","Hello","Users"] diff --git a/framework/cli/simple_module_cli/templates/workspace/Makefile b/framework/cli/simple_module_cli/templates/workspace/Makefile index daadb2cd..2e7f704f 100644 --- a/framework/cli/simple_module_cli/templates/workspace/Makefile +++ b/framework/cli/simple_module_cli/templates/workspace/Makefile @@ -3,6 +3,7 @@ install: uv sync --all-packages npm install + $(MAKE) sync-module-deps dev: gen-pages @echo "Starting API and UI dev servers..." @@ -18,14 +19,15 @@ build: npm run build # Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} -# from installed modules (workspace + wheel-installed). +# from installed modules (workspace + wheel-installed). Delegates to the +# host's Makefile so the workspace and flat layouts share one command. gen-pages: - uv run --project host sm host gen-pages --host-dir=host/client_app + $(MAKE) -C host gen-pages # Pull JS deps shipped by wheel-installed modules into host/client_app/node_modules. # Workspace modules under modules/* don't need this — npm hoists them automatically. sync-module-deps: - uv run --project host sm host sync-js-deps --host-client-app=host/client_app + $(MAKE) -C host sync-js-deps migrate: $(MAKE) -C host migrate @@ -36,5 +38,5 @@ migration: kill: @-pkill -f "uvicorn main:app" 2>/dev/null @-pkill -f vite 2>/dev/null - @-lsof -ti:8000,5173 | xargs kill -9 2>/dev/null - @echo "Ports 8000, 5173 freed." + @-lsof -ti:8000,5050 | xargs kill -9 2>/dev/null + @echo "Ports 8000, 5050 freed." diff --git a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl index 84e5e7a9..f8656f01 100644 --- a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl @@ -31,7 +31,7 @@ make migrate make dev ``` -The API listens on http://localhost:8000 and Vite on http://localhost:5173. +The API listens on http://localhost:8000 and Vite on http://localhost:5050. ## Adding a module From d14798bbc5c04a916371c1e969a9db731acddd9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 17:03:04 +0000 Subject: [PATCH 4/8] fix(cli): make wheel-installed module pages render in workspace mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building on the origin/main fixes for #110 / #115 and #119 / #116, this commit closes the remaining gaps that surfaced during a real end-to-end smoke test (`sm new` → `make install` → `make migrate` → `make dev`): 1. **React preamble**: the host's `templates/index.html` didn't inject the `__vite_plugin_react_preamble_installed__` global. Without it, plugin-react throws "can't detect preamble" on every wheel-installed `.tsx` module page and React never mounts. Mirror the framework repo's own `host/templates/index.html` and inject the preamble in dev mode. 2. **Vite fs.allow root**: `server.fs.allow` was scoped to `path.resolve(__dirname, '..')`, i.e. the host root. In workspace mode `node_modules` is hoisted to the workspace root one level higher, so vite refused to serve hoisted React. Walk up from `client_app/` to the directory that owns `node_modules` and use that as the serve root. 3. **Bare-import pre-bundling**: vite's optimizer never scanned wheel-installed module pages because they sit outside the project root, so CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority` reached the browser without named ESM exports. Two changes: - `optimizeDeps.entries`: add the manifest's per-module pages dirs so the scanner crawls them. - `optimizeDeps.include`: walk `host/client_app/package.json` plus each declared dep's package.json (filesystem walk, since exports maps frequently exclude `./package.json`) and force-include every reachable package that has a top-level entry. `@simple-module-py/ui`'s transitive deps (`clsx`, `cmdk`, `radix-ui`, etc.) get pre-bundled and named imports work everywhere. 4. **Dedupe**: also dedupe `@inertiajs/react`, `@simple-module-py/ui`, `@simple-module-py/i18n`. Without it, wheel pages and host pages can end up with separate `usePage` contexts → "usePage must be used within the Inertia component" runtime error. After these changes, `make dev` against a fresh `sm new` checkout renders the full login page with CSS, JS, and React all live; the sign-in button transitions to "Signing in…" on submit. No console errors, no network failures, nothing in the API log past INFO request lines. --- .../cli/simple_module_cli/app_project.py | 2 +- .../templates/host/client_app/vite.config.ts | 146 +++++++++++------- .../templates/host/templates/index.html | 9 ++ framework/cli/tests/test_cli_new.py | 26 +--- 4 files changed, 103 insertions(+), 80 deletions(-) diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 61d3c58a..7d72b443 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -198,7 +198,7 @@ def _pin_sample_module_deps(sample_dest: Path) -> None: def _pin_or_keep(dep: str) -> str: """Pin a ``simple_module_*`` requirement to the framework version; pass through otherwise.""" pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip() - if pkg.startswith("simple_module_") or pkg.startswith("simple-module-"): + if pkg.startswith(("simple_module_", "simple-module-")): return f"{pkg}=={_FRAMEWORK_VERSION}" return dep diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index 8f064134..f29543c4 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -1,18 +1,12 @@ import fs from 'node:fs'; -import { createRequire } from 'node:module'; import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; -import { defineConfig, type Plugin } from 'vite'; - -// Host project boundary — used by the resolver plugin below to decide -// whether an importer needs re-rooting. Always one level up from -// client_app/, regardless of layout. -const hostRoot = path.resolve(__dirname, '..'); +import { defineConfig } from 'vite'; // File-system serve root — the directory that holds `node_modules`. In flat -// mode that's `hostRoot`; in workspace mode npm hoists `node_modules` to the -// workspace root one level higher, so we walk up to find it. +// mode that's the host root; in workspace mode npm hoists `node_modules` to +// the workspace root one level higher, so we walk up to find it. function findNodeModulesRoot(start: string): string { let dir = start; while (dir !== path.dirname(dir)) { @@ -26,74 +20,116 @@ const fsRoot = findNodeModulesRoot(__dirname); // Load the module pages manifest written by the Python host at boot. // Each entry points at an absolute pages/ directory — typically inside a // pip-installed module wheel. Vite needs these in server.fs.allow so the -// dev server can read files outside the host root. +// dev server can read files outside the host root, and in +// optimizeDeps.entries so its dependency scanner discovers bare imports +// from wheel-installed pages and pre-bundles them. const manifestPath = path.resolve(__dirname, 'modules.manifest.json'); const moduleFsAllow: string[] = []; +const moduleOptimizeEntries: string[] = []; if (fs.existsSync(manifestPath)) { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record; for (const pagesDir of Object.values(manifest)) { moduleFsAllow.push(path.dirname(pagesDir)); + moduleOptimizeEntries.push(path.join(pagesDir, '**/*.tsx')); + } +} + +// CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority` +// expose named exports only after esbuild's CJS→ESM transform. Vite's +// optimizer would normally pre-bundle them on first import, but the +// scanner sometimes misses bare imports inside wheel-installed pages +// because their importer paths sit outside the project root. Walking +// host/client_app/package.json + every dep's package.json one level +// deep and force-including the result keeps the named-import contract +// for everything pulled in transitively by `@simple-module-py/ui` etc. +function findPackageJSON(name: string): string | null { + let dir = __dirname; + while (true) { + const candidate = path.join(dir, 'node_modules', name, 'package.json'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; } } -// Module .tsx files live in `.venv/.../site-packages//pages/`. Vite's -// default resolver walks UP from the importing file looking for -// `node_modules/`, but the host's `node_modules/` is in `client_app/` — a -// sibling of the venv, not an ancestor. Bare imports from module pages -// (`@simple-module-py/ui`, `lucide-react`, …) therefore fail with -// "could not be resolved" even though the host has them installed. -// -// This plugin re-roots bare-import resolution at the host's node_modules -// when the importer lives outside the project. It runs `pre` so it beats -// vite's built-in resolver. -const hostRequire = createRequire(path.join(__dirname, 'package.json')); -const resolveCache = new Map(); +function hasTopLevelEntry(pkgJsonPath: string): boolean { + let pkg: { main?: string; module?: string; exports?: unknown }; + try { + pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + } catch { + return false; + } + if (pkg.main || pkg.module) return true; + const exp = pkg.exports; + if (typeof exp === 'string') return true; + if (exp && typeof exp === 'object') return '.' in exp; + return false; +} -function resolveFromHost(): Plugin { - return { - name: 'resolve-module-imports-from-host', - enforce: 'pre', - resolveId(source, importer) { - if (!importer) return null; - if (source.startsWith('.') || source.startsWith('/')) return null; - if (importer.startsWith(hostRoot + path.sep)) return null; - let resolved = resolveCache.get(source); - if (resolved === undefined) { - try { - resolved = hostRequire.resolve(source); - } catch { - resolved = null; - } - resolveCache.set(source, resolved); +function collectOptimizeIncludes(): string[] { + const seeded = [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@inertiajs/react', + 'use-sync-external-store', + 'use-sync-external-store/shim', + 'use-sync-external-store/shim/with-selector', + ]; + const includes = new Set(seeded); + const visited = new Set(); + const queue: string[] = [path.join(__dirname, 'package.json')]; + while (queue.length > 0) { + const pkgJsonPath = queue.shift(); + if (!pkgJsonPath || visited.has(pkgJsonPath)) continue; + visited.add(pkgJsonPath); + let pkg: { dependencies?: Record }; + try { + pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + } catch { + continue; + } + for (const name of Object.keys(pkg.dependencies ?? {})) { + if (name.startsWith('@types/')) continue; + const nested = findPackageJSON(name); + if (!nested) continue; + // Skip packages that ship only sub-paths (`@babel/runtime`); vite + // refuses to pre-bundle them and bare imports against them resolve + // naturally through Node's normal module-walk anyway. + if (hasTopLevelEntry(nested)) { + includes.add(name); } - return resolved; - }, - }; + queue.push(nested); + } + } + return [...includes]; } export default defineConfig({ - plugins: [resolveFromHost(), react(), tailwindcss()], + plugins: [react(), tailwindcss()], root: __dirname, - // Force every importer to resolve to one React copy — without it, - // plugin-react's Fast Refresh preamble check fires in a realm where its - // global was never set ("can't detect preamble"). + // Force every importer (host pages, workspace modules, wheel-installed + // modules) to resolve to one React copy. Without this, plugin-react's + // Fast Refresh preamble check fires in a realm where its global was + // never set ("can't detect preamble"). resolve: { - dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'], - }, - // ``use-sync-external-store`` is the CJS shim recharts/react-redux pull - // in; pre-bundling resolves its named export under ESM. - optimizeDeps: { - include: [ + dedupe: [ 'react', 'react-dom', - 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime', - 'use-sync-external-store', - 'use-sync-external-store/shim', - 'use-sync-external-store/shim/with-selector', + '@inertiajs/react', + '@simple-module-py/ui', + '@simple-module-py/i18n', ], }, + optimizeDeps: { + entries: ['main.tsx', 'pages/**/*.tsx', ...moduleOptimizeEntries], + include: collectOptimizeIncludes(), + }, build: { outDir: '../static/dist', manifest: true, diff --git a/framework/cli/simple_module_cli/templates/host/templates/index.html b/framework/cli/simple_module_cli/templates/host/templates/index.html index 785aba27..b7b5c451 100644 --- a/framework/cli/simple_module_cli/templates/host/templates/index.html +++ b/framework/cli/simple_module_cli/templates/host/templates/index.html @@ -5,6 +5,15 @@ SimpleModule {% inertia_head %} + {% if request.app.state.sm.inertia_config.environment == "development" %} + + {% endif %} {% inertia_body %} diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 5d3bd721..f23e15f9 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -16,8 +16,6 @@ def test_sm_new_generates_pyproject_with_expected_deps(tmp_path: Path) -> None: app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) - # Workspace mode: framework/module deps live in host/pyproject.toml, - # not the workspace-root pyproject. pyproject_text = (target / "host" / "pyproject.toml").read_text() for required in ( "simple_module_hosting", @@ -35,8 +33,6 @@ def test_sm_new_generates_package_json_with_npm_deps(tmp_path: Path) -> None: app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) - # Workspace mode: app npm deps land in host/client_app/package.json - # (the npm workspace member), not the top-level workspace package.json. data = json.loads((target / "host" / "client_app" / "package.json").read_text()) assert "@simple-module-py/ui" in data.get("dependencies", {}) assert "@simple-module-py/i18n" in data.get("dependencies", {}) @@ -44,9 +40,6 @@ def test_sm_new_generates_package_json_with_npm_deps(tmp_path: Path) -> None: def test_sm_new_pins_client_app_simple_module_deps_to_framework_version(tmp_path: Path) -> None: - # Caret on a 0.0.x version is locked to that exact patch, so a stale - # template pin silently downgrades fresh installs. The scaffold must - # substitute the running CLI's own version into client_app/package.json. from importlib.metadata import version expected = version("simple_module_cli") @@ -224,17 +217,14 @@ def test_sm_new_default_scaffolds_sample_hello_module(tmp_path: Path) -> None: def test_sm_new_default_lays_down_workspace_layout(tmp_path: Path) -> None: - """Default mode mirrors the framework repo: workspace root + host/ subdir + modules/.""" runner = CliRunner() target = tmp_path / "demo" runner.invoke( app, ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) - # Workspace root files for relpath in ("pyproject.toml", "package.json", "Makefile", ".env.example"): assert (target / relpath).is_file(), f"missing workspace root file: {relpath}" - # Host moves under host/ for relpath in ( "main.py", "alembic.ini", @@ -243,20 +233,11 @@ def test_sm_new_default_lays_down_workspace_layout(tmp_path: Path) -> None: "client_app/vite.config.ts", ): assert (target / "host" / relpath).is_file(), f"missing host file: {relpath}" - # The host's own copies of files the workspace owns are stripped to - # avoid two stale .env.example / README.md hanging around. - assert not (target / "host" / ".env.example").exists() - assert not (target / "host" / "README.md").exists() - assert not (target / "host" / ".gitignore").exists() + for relpath in (".env.example", "README.md", ".gitignore"): + assert not (target / "host" / relpath).exists() def test_sm_new_default_wires_workspace_in_pyproject(tmp_path: Path) -> None: - """Default mode declares ``host`` and ``modules/*`` as uv workspace members. - - The workspace root pyproject is bare (no app deps); the host's - pyproject carries the simple_module_* deps and a [tool.uv.sources] - entry pointing the sample at the workspace, not PyPI. - """ runner = CliRunner() target = tmp_path / "demo" runner.invoke( @@ -275,7 +256,6 @@ def test_sm_new_default_wires_workspace_in_pyproject(tmp_path: Path) -> None: 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( @@ -283,8 +263,6 @@ def test_sm_new_default_adds_npm_workspaces_field(tmp_path: Path) -> None: ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) data = json.loads((target / "package.json").read_text()) - # host/client_app + every module is hoisted into one node_modules so - # vite's resolver finds bare imports without per-module aliasing. assert data.get("workspaces") == ["host/client_app", "modules/*"] From e99d0737c7a015c6a0afb1d1782e90936333b692 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 17:04:19 +0000 Subject: [PATCH 5/8] fix(cli): satisfy ty on tomlkit subscript assignment `doc["project"]` returns `Item | Container` and `Item` has no `__setitem__`. Use `doc.setdefault("project", tomlkit.table())` (matching `_rewrite_pyproject` next door) so ty sees a writeable `Container`. --- framework/cli/simple_module_cli/app_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 7d72b443..cf939c59 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -186,7 +186,7 @@ def _pin_sample_module_deps(sample_dest: Path) -> None: pyproject = sample_dest / "pyproject.toml" doc = tomlkit.parse(pyproject.read_text(encoding="utf-8")) - project = doc["project"] + project = doc.setdefault("project", tomlkit.table()) project["dependencies"] = [_pin_or_keep(dep) for dep in project.get("dependencies", [])] optional = project.get("optional-dependencies") if optional is not None: From 0cdec6aca142e6e7ea56ca2fa15202af402aad2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 21:29:24 +0000 Subject: [PATCH 6/8] refactor(cli): tighten scaffold helpers after simplify pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.config.ts: - Cache parsed package.json reads via a `Map` so the BFS in collectOptimizeIncludes doesn't read each dep's package.json twice (once for hasTopLevelEntry, once for transitive deps). - findPackageJSON checks fsRoot/node_modules directly instead of re-walking ancestors per dep — that walk was already done once for fsRoot. - Extract REACT_CORE_DEPS so dedupe and optimizeDeps.include can't drift apart silently. app_project.py: - Inline the one-line _write_flat_top_level_package_json wrapper into its single caller. - Split _seed_static_dist_placeholder out of _scaffold_sample_module so the function name matches what it does. templates/host/templates/index.html: - Restore the Google Fonts s present in the framework's own host/templates/index.html; without them the scaffolded UI silently falls back to system fonts. --- .../cli/simple_module_cli/app_project.py | 41 ++++----- .../templates/host/client_app/vite.config.ts | 88 ++++++++++--------- .../templates/host/templates/index.html | 3 + 3 files changed, 67 insertions(+), 65 deletions(-) diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index cf939c59..d48f295a 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -145,7 +145,18 @@ def create_app_project( host_pyproject.write_text(text, encoding="utf-8") if flat: - _write_flat_top_level_package_json(target, name=name) + # The workspace template already emits a top-level package.json + # with workspaces; flat mode has none, so seed one with the + # framework npm pins so `npm install` resolves at the root. + pkg_path = target / "package.json" + pkg_data: dict[str, Any] = ( + _json.loads(pkg_path.read_text(encoding="utf-8")) + if pkg_path.exists() + else {"name": to_kebab_case(name), "private": True, "type": "module"} + ) + pkg_data.setdefault("dependencies", {}).update(_APP_NPM_DEPS) + pkg_data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) + pkg_path.write_text(_json.dumps(pkg_data, indent=2) + "\n", encoding="utf-8") ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) for mod_name in resolved: @@ -166,10 +177,12 @@ def _scaffold_sample_module(target: Path) -> None: return create_module(sample_dest, name=_SAMPLE_MODULE_NAME) _pin_sample_module_deps(sample_dest) - # Hatch's force-include directive resolves at build time even for - # editable installs; an empty placeholder dir keeps `uv sync` from - # failing before the user has run vite build. - static_dist = sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist" + _seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist") + + +def _seed_static_dist_placeholder(static_dist: Path) -> None: + # Hatch's force-include resolves at build time even for editable installs; + # an empty placeholder keeps `uv sync` from failing before vite build runs. static_dist.mkdir(parents=True, exist_ok=True) (static_dist / ".gitkeep").touch() @@ -203,24 +216,6 @@ def _pin_or_keep(dep: str) -> str: return dep -def _write_flat_top_level_package_json(target: Path, *, name: str) -> None: - """In flat mode the host template doesn't ship a top-level ``package.json``. - - Create one so ``npm install`` from the project root resolves the - framework npm deps. Workspace mode doesn't need this — the workspace - template already emits a workspaces-aware top-level package.json. - """ - 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) - pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") - - def _db_url(db: str, slug: str, *, flat: bool) -> str: if db == "postgres": return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index f29543c4..6fb200e5 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -4,6 +4,18 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +// Force every importer (host, workspace module, wheel-installed module) +// to resolve to one React copy + a single Inertia hook context. Without +// dedupe, plugin-react fires "can't detect preamble" and `usePage` from +// a wheel-loaded page lands in a different React realm than the host's. +const REACT_CORE_DEPS = [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@inertiajs/react', +] as const; + // File-system serve root — the directory that holds `node_modules`. In flat // mode that's the host root; in workspace mode npm hoists `node_modules` to // the workspace root one level higher, so we walk up to find it. @@ -42,24 +54,35 @@ if (fs.existsSync(manifestPath)) { // host/client_app/package.json + every dep's package.json one level // deep and force-including the result keeps the named-import contract // for everything pulled in transitively by `@simple-module-py/ui` etc. -function findPackageJSON(name: string): string | null { - let dir = __dirname; - while (true) { - const candidate = path.join(dir, 'node_modules', name, 'package.json'); - if (fs.existsSync(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} +type Pkg = { + main?: string; + module?: string; + exports?: unknown; + dependencies?: Record; +}; + +const pkgCache = new Map(); -function hasTopLevelEntry(pkgJsonPath: string): boolean { - let pkg: { main?: string; module?: string; exports?: unknown }; +function readPackageJSON(pkgJsonPath: string): Pkg | null { + let pkg = pkgCache.get(pkgJsonPath); + if (pkg !== undefined) return pkg; try { - pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as Pkg; } catch { - return false; + pkg = null; } + pkgCache.set(pkgJsonPath, pkg); + return pkg; +} + +function findPackageJSON(name: string): string | null { + // npm hoists into `fsRoot/node_modules`; that's the only location worth + // checking in workspace + flat layouts alike. + const candidate = path.join(fsRoot, 'node_modules', name, 'package.json'); + return fs.existsSync(candidate) ? candidate : null; +} + +function hasTopLevelEntry(pkg: Pkg): boolean { if (pkg.main || pkg.module) return true; const exp = pkg.exports; if (typeof exp === 'string') return true; @@ -68,38 +91,31 @@ function hasTopLevelEntry(pkgJsonPath: string): boolean { } function collectOptimizeIncludes(): string[] { - const seeded = [ - 'react', - 'react-dom', + const includes = new Set([ + ...REACT_CORE_DEPS, 'react-dom/client', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - '@inertiajs/react', 'use-sync-external-store', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector', - ]; - const includes = new Set(seeded); + ]); const visited = new Set(); const queue: string[] = [path.join(__dirname, 'package.json')]; while (queue.length > 0) { const pkgJsonPath = queue.shift(); if (!pkgJsonPath || visited.has(pkgJsonPath)) continue; visited.add(pkgJsonPath); - let pkg: { dependencies?: Record }; - try { - pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); - } catch { - continue; - } + const pkg = readPackageJSON(pkgJsonPath); + if (!pkg) continue; for (const name of Object.keys(pkg.dependencies ?? {})) { if (name.startsWith('@types/')) continue; const nested = findPackageJSON(name); if (!nested) continue; + const nestedPkg = readPackageJSON(nested); + if (!nestedPkg) continue; // Skip packages that ship only sub-paths (`@babel/runtime`); vite // refuses to pre-bundle them and bare imports against them resolve // naturally through Node's normal module-walk anyway. - if (hasTopLevelEntry(nested)) { + if (hasTopLevelEntry(nestedPkg)) { includes.add(name); } queue.push(nested); @@ -111,20 +127,8 @@ function collectOptimizeIncludes(): string[] { export default defineConfig({ plugins: [react(), tailwindcss()], root: __dirname, - // Force every importer (host pages, workspace modules, wheel-installed - // modules) to resolve to one React copy. Without this, plugin-react's - // Fast Refresh preamble check fires in a realm where its global was - // never set ("can't detect preamble"). resolve: { - dedupe: [ - 'react', - 'react-dom', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - '@inertiajs/react', - '@simple-module-py/ui', - '@simple-module-py/i18n', - ], + dedupe: [...REACT_CORE_DEPS, '@simple-module-py/ui', '@simple-module-py/i18n'], }, optimizeDeps: { entries: ['main.tsx', 'pages/**/*.tsx', ...moduleOptimizeEntries], diff --git a/framework/cli/simple_module_cli/templates/host/templates/index.html b/framework/cli/simple_module_cli/templates/host/templates/index.html index b7b5c451..ea8da7ea 100644 --- a/framework/cli/simple_module_cli/templates/host/templates/index.html +++ b/framework/cli/simple_module_cli/templates/host/templates/index.html @@ -4,6 +4,9 @@ SimpleModule + + + {% inertia_head %} {% if request.app.state.sm.inertia_config.environment == "development" %}