From fa2e7e05638f69aea00aa945a347f601271b6ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 11:32:14 +0000 Subject: [PATCH 1/2] fix(scaffold): pin client_app deps to framework version + resolve module imports from host (#119, #116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #119: client_app/package.json template hard-coded ^0.0.3 for @simple-module-py/{ui,i18n}. Caret on a 0.0.x version is locked to that exact patch, so a stale pin silently downgrades fresh installs even when newer versions ship the fixes they need. Substitute the running CLI's own framework version into the template at scaffold time so the pin always matches the rest of the wheel set. - #116 (vite resolution): module .tsx files live in .venv/.../site-packages// and vite's resolver walks up looking for node_modules/ — but the host's node_modules/ is in client_app/, a sibling of the venv, not an ancestor. Add a 'pre' resolver plugin to the scaffold's vite.config.ts that re-roots bare-import resolution at the host's node_modules whenever the importer lives outside the project. Generic — works for any module dep without per-package alias plumbing. https://claude.ai/code/session_01LmSbHXftZJ2p8vGs1TGYBZ --- .../cli/simple_module_cli/app_project.py | 2 +- .../cli/simple_module_cli/scaffolding.py | 7 +++- .../host/client_app/package.json.tpl | 4 +- .../templates/host/client_app/vite.config.ts | 41 ++++++++++++++++++- framework/cli/tests/test_cli_new.py | 23 +++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) 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..6afe5908 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, '..'); @@ -12,15 +13,51 @@ const projectRoot = path.resolve(__dirname, '..'); // dev server can read files outside the host root. const manifestPath = path.resolve(__dirname, 'modules.manifest.json'); const moduleFsAllow: string[] = []; +const moduleRoots: 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)); + moduleRoots.push(pagesDir); } } +// 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. (See issue #116.) +const hostRequire = createRequire(path.join(__dirname, 'package.json')); + +function resolveFromHost(): Plugin { + return { + name: 'resolve-module-imports-from-host', + enforce: 'pre', + resolveId(source, importer) { + if (!importer) return null; + // Relative + absolute paths use the default resolver. + if (source.startsWith('.') || source.startsWith('/')) return null; + // Only re-root when the importer lives outside the project (i.e. in + // a venv site-packages dir or any other module root). Importers + // already inside client_app/ resolve normally. + const inHost = importer.startsWith(projectRoot + path.sep); + if (inHost) return null; + try { + return hostRequire.resolve(source); + } catch { + return null; + } + }, + }; +} + 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..435671d4 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -50,6 +50,29 @@ 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: + """Regression for #119: the client_app/package.json must not ship stale + ``^0.0.x`` pins for ``@simple-module-py/*``. Caret on a 0.0.x version is + locked to that exact patch, so a stale pin silently downgrades users on + every fresh install. The scaffold must substitute the running CLI's own + framework version into the template at scaffold time. + """ + from simple_module_cli.app_project import _FRAMEWORK_VERSION + + 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) == _FRAMEWORK_VERSION, ( + f"{pkg} should be pinned to {_FRAMEWORK_VERSION}, got {deps.get(pkg)!r}" + ) + + def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "my-app" From ad8adadd588ef1b563f9d1de7eb68d6f778aa165 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 14:37:57 +0000 Subject: [PATCH 2/2] chore: simplify resolveFromHost (cache + drop dead state) and tighten regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vite.config.ts: remove unused `moduleRoots` array; memoize `hostRequire.resolve` so repeat bare specifiers (`react`, `lucide-react`, `@simple-module-py/ui`, …) skip the FS walk on every transform. - test_cli_new.py: read the expected version via importlib.metadata instead of importing the private `_FRAMEWORK_VERSION` symbol; drop the ticket-narrating docstring. https://claude.ai/code/session_01LmSbHXftZJ2p8vGs1TGYBZ --- .../templates/host/client_app/vite.config.ts | 25 +++++++++---------- framework/cli/tests/test_cli_new.py | 16 ++++++------ 2 files changed, 19 insertions(+), 22 deletions(-) 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 6afe5908..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 @@ -13,12 +13,10 @@ const projectRoot = path.resolve(__dirname, '..'); // dev server can read files outside the host root. const manifestPath = path.resolve(__dirname, 'modules.manifest.json'); const moduleFsAllow: string[] = []; -const moduleRoots: 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)); - moduleRoots.push(pagesDir); } } @@ -31,8 +29,9 @@ if (fs.existsSync(manifestPath)) { // // 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. (See issue #116.) +// vite's built-in resolver. const hostRequire = createRequire(path.join(__dirname, 'package.json')); +const resolveCache = new Map(); function resolveFromHost(): Plugin { return { @@ -40,18 +39,18 @@ function resolveFromHost(): Plugin { enforce: 'pre', resolveId(source, importer) { if (!importer) return null; - // Relative + absolute paths use the default resolver. if (source.startsWith('.') || source.startsWith('/')) return null; - // Only re-root when the importer lives outside the project (i.e. in - // a venv site-packages dir or any other module root). Importers - // already inside client_app/ resolve normally. - const inHost = importer.startsWith(projectRoot + path.sep); - if (inHost) return null; - try { - return hostRequire.resolve(source); - } catch { - 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; }, }; } diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 435671d4..26ac2c71 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -51,14 +51,12 @@ 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: - """Regression for #119: the client_app/package.json must not ship stale - ``^0.0.x`` pins for ``@simple-module-py/*``. Caret on a 0.0.x version is - locked to that exact patch, so a stale pin silently downgrades users on - every fresh install. The scaffold must substitute the running CLI's own - framework version into the template at scaffold time. - """ - from simple_module_cli.app_project import _FRAMEWORK_VERSION + # 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( @@ -68,8 +66,8 @@ def test_sm_new_pins_client_app_simple_module_deps_to_framework_version(tmp_path 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) == _FRAMEWORK_VERSION, ( - f"{pkg} should be pinned to {_FRAMEWORK_VERSION}, got {deps.get(pkg)!r}" + assert deps.get(pkg) == expected, ( + f"{pkg} should be pinned to {expected}, got {deps.get(pkg)!r}" )