diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index fb85f6d4..20815097 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,15 @@ def _resolve_framework_version() -> str: "vite": "^8.0.0", } +# 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", + "Makefile", +) + def create_app_project( target: Path, @@ -77,9 +92,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 +113,55 @@ 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) + 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") - 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") - - 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: + # 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: @@ -135,45 +170,95 @@ 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) + _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() + + +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.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: + 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_", "simple-module-")): + return f"{pkg}=={_FRAMEWORK_VERSION}" + return dep -def _db_url(db: str, slug: str) -> str: + +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" + # 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( + 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/recipes.py b/framework/cli/simple_module_cli/recipes.py index 31aed925..c3400736 100644 --- a/framework/cli/simple_module_cli/recipes.py +++ b/framework/cli/simple_module_cli/recipes.py @@ -49,16 +49,22 @@ def _optional_template_root(name: str) -> Path: class BackgroundTasksRecipe: - """Lays down run_worker.py + compose + Dockerfile + Make targets.""" + """Lays down run_worker.py + compose + Dockerfiles + Make targets.""" def apply(self, target: Path, ctx: ScaffoldCtx) -> None: templates = _optional_template_root("background_tasks") run_worker_dest = target / "scripts" / "run_worker.py" compose_dest = target / "docker-compose.yml" - dockerfile_dest = target / "docker" / "worker.Dockerfile" - - for path in (run_worker_dest, compose_dest, dockerfile_dest): + host_dockerfile_dest = target / "docker" / "host.Dockerfile" + worker_dockerfile_dest = target / "docker" / "worker.Dockerfile" + + for path in ( + run_worker_dest, + compose_dest, + host_dockerfile_dest, + worker_dockerfile_dest, + ): if path.exists(): raise FileExistsError( f"{path} already exists — refusing to clobber. " @@ -70,8 +76,9 @@ def apply(self, target: Path, ctx: ScaffoldCtx) -> None: shutil.copy2(templates / "docker-compose.yml", compose_dest) - dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest) + host_dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "host.Dockerfile", host_dockerfile_dest) + shutil.copy2(templates / "worker.Dockerfile", worker_dockerfile_dest) env_path = target / ".env.example" env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" 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/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/host/_optional/background_tasks/docker-compose.yml b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml index 46a1493f..40f13dff 100644 --- a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +++ b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml @@ -1,4 +1,20 @@ services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: simple_module + POSTGRES_USER: sm + POSTGRES_PASSWORD: sm + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U sm -d simple_module"] + interval: 5s + timeout: 5s + retries: 10 + redis: image: redis:7-alpine ports: @@ -11,6 +27,19 @@ services: timeout: 3s retries: 10 + host: + build: + context: . + dockerfile: docker/host.Dockerfile + env_file: .env + environment: + SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module} + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + worker: build: context: . @@ -19,9 +48,12 @@ services: environment: SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module} depends_on: redis: condition: service_healthy + postgres: + condition: service_healthy command: - "uv" - "run" @@ -41,6 +73,7 @@ services: environment: SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module} depends_on: redis: condition: service_healthy @@ -57,4 +90,5 @@ services: - "info" volumes: + pgdata: redisdata: diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile new file mode 100644 index 00000000..3d400f8d --- /dev/null +++ b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile @@ -0,0 +1,44 @@ +# FastAPI host image. Multi-stage: Node builds the Vite client bundle, +# Python serves uvicorn. Migrations run on container start. + +FROM node:22-slim AS frontend +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY host/client_app/package.json host/client_app/ +COPY modules/ modules/ +RUN npm ci --workspaces --include-workspace-root +COPY host/ host/ +RUN npm --workspace host/client_app run build + +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_SYSTEM_PYTHON=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir uv + +WORKDIR /app + +COPY pyproject.toml uv.lock* ./ +COPY host/pyproject.toml host/ +COPY modules/ modules/ +RUN uv sync --all-packages --no-dev + +COPY host/ host/ +COPY --from=frontend /app/host/static/dist host/static/dist + +RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \ + && chown -R app:app /app +USER app + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +CMD ["sh", "-c", "cd host && uv run alembic upgrade head && uv run uvicorn main:app --host 0.0.0.0 --port 8000"] 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..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 @@ -1,82 +1,138 @@ 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'; +import { defineConfig } from 'vite'; -const projectRoot = path.resolve(__dirname, '..'); +// 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. +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 // 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')); } } -// 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(); +// 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. +type Pkg = { + main?: string; + module?: string; + exports?: unknown; + dependencies?: Record; +}; + +const pkgCache = new Map(); + +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')) as Pkg; + } catch { + pkg = null; + } + pkgCache.set(pkgJsonPath, pkg); + return pkg; +} -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); +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; + if (exp && typeof exp === 'object') return '.' in exp; + return false; +} + +function collectOptimizeIncludes(): string[] { + const includes = new Set([ + ...REACT_CORE_DEPS, + 'react-dom/client', + 'use-sync-external-store', + 'use-sync-external-store/shim', + 'use-sync-external-store/shim/with-selector', + ]); + 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); + 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(nestedPkg)) { + 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"). resolve: { - dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'], + dedupe: [...REACT_CORE_DEPS, '@simple-module-py/ui', '@simple-module-py/i18n'], }, - // ``use-sync-external-store`` is the CJS shim recharts/react-redux pull - // in; pre-bundling resolves its named export under ESM. optimizeDeps: { - include: [ - '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', - ], + entries: ['main.tsx', 'pages/**/*.tsx', ...moduleOptimizeEntries], + include: collectOptimizeIncludes(), }, build: { outDir: '../static/dist', @@ -90,7 +146,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/host/templates/index.html b/framework/cli/simple_module_cli/templates/host/templates/index.html index 785aba27..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,7 +4,19 @@ SimpleModule + + + {% inertia_head %} + {% if request.app.state.sm.inertia_config.environment == "development" %} + + {% endif %} {% inertia_body %} 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..9ec3db13 --- /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:5050 + +# 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..84dfafa3 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/workspace/Makefile @@ -0,0 +1,42 @@ +.PHONY: install dev dev-api dev-ui build migrate migration gen-pages sync-module-deps kill + +install: + uv sync --all-packages + npm install + $(MAKE) sync-module-deps + +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: + cd host && uv run python -m simple_module_hosting gen-pages --host-dir=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: + cd host && uv run python -m simple_module_hosting sync-js-deps --host-client-app=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,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 new file mode 100644 index 00000000..f8656f01 --- /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:5050. + +## 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..b51251b2 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,7 @@ 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() + pyproject_text = (target / "host" / "pyproject.toml").read_text() for required in ( "simple_module_hosting", "simple_module_users", @@ -44,16 +33,13 @@ 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()) + 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", {}) 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") @@ -63,7 +49,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 +83,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 @@ -117,6 +103,7 @@ def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> assert (target / "scripts" / "run_worker.py").is_file() assert (target / "docker-compose.yml").is_file() + assert (target / "docker" / "host.Dockerfile").is_file() assert (target / "docker" / "worker.Dockerfile").is_file() makefile_text = (target / "Makefile").read_text() assert "worker:" in makefile_text @@ -127,7 +114,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 +133,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 +156,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 +182,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,25 +217,46 @@ 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: + runner = CliRunner() + target = tmp_path / "demo" + runner.invoke( + app, + ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + for relpath in ("pyproject.toml", "package.json", "Makefile", ".env.example"): + assert (target / relpath).is_file(), f"missing workspace root file: {relpath}" + 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}" + for relpath in (".env.example", "README.md", ".gitignore", "Makefile"): + assert not (target / "host" / relpath).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.""" 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: - """Default mode declares ``workspaces`` so vite picks up modules//.""" runner = CliRunner() target = tmp_path / "demo" runner.invoke( @@ -256,7 +264,7 @@ 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/*"] + assert data.get("workspaces") == ["host/client_app", "modules/*"] def test_sm_new_flat_skips_modules_dir(tmp_path: Path) -> None: