diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 1b7547fd..fb85f6d4 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -93,7 +93,7 @@ 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) + create_host(target, name=name, modules=display_names, framework_version=_FRAMEWORK_VERSION) py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index 5fec6173..1c1491d6 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -85,6 +85,7 @@ def create_host( name: str, modules: Sequence[str], template_root: Path | None = None, + framework_version: str = "*", ) -> Path: dest = Path(dest) _require_empty_dest(dest) @@ -92,7 +93,11 @@ def create_host( _apply_template_files( _resolve_template_root("host", template_root), dest, - {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines}, + { + "{{HOST_NAME}}": name, + "{{MODULE_DEPS}}": module_dep_lines, + "{{FRAMEWORK_VERSION}}": framework_version, + }, ) logger.info( "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" 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 2ff64876..b78f4500 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 @@ -9,8 +9,8 @@ }, "dependencies": { "@inertiajs/react": "^2.0.0", - "@simple-module-py/i18n": "^0.0.3", - "@simple-module-py/ui": "^0.0.3", + "@simple-module-py/i18n": "{{FRAMEWORK_VERSION}}", + "@simple-module-py/ui": "{{FRAMEWORK_VERSION}}", "react": "^19.0.0", "react-dom": "^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 5bed11e8..a0df7657 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,8 +1,9 @@ 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 } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; const projectRoot = path.resolve(__dirname, '..'); @@ -19,8 +20,43 @@ if (fs.existsSync(manifestPath)) { } } +// 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 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(projectRoot + path.sep)) return null; + let resolved = resolveCache.get(source); + if (resolved === undefined) { + try { + resolved = hostRequire.resolve(source); + } catch { + resolved = null; + } + resolveCache.set(source, resolved); + } + return resolved; + }, + }; +} + export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [resolveFromHost(), 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 diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 264fd802..26ac2c71 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -50,6 +50,27 @@ def test_sm_new_generates_package_json_with_npm_deps(tmp_path: Path) -> None: assert "@simple-module-py/tsconfig" in data.get("devDependencies", {}) +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") + runner = CliRunner() + target = tmp_path / "my-app" + runner.invoke( + app, + ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + data = json.loads((target / "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, ( + f"{pkg} should be pinned to {expected}, got {deps.get(pkg)!r}" + ) + + def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "my-app"