Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b

### Fixed

- A fresh app's `make dev` no longer prints Vite errors or warnings, and `npm audit` reports nothing. The `simple_module_settings` wheel no longer ships `ui/components/validation-errors.test.ts`: that test made the host's Vite dependency scan fail with "Failed to run dependency scan … vitest". It now lives in `modules/settings/tests-js/`, and `make ci-build-packages` fails if any wheel contains a JS or TS `*.test.*` or `*.spec.*` file, a `test-dom.tsx` or a `page-tests/` directory. The scaffold's `client_app/vite.config.ts` loads under Vite 8's native config loader: it uses `import.meta.dirname` and `.ts` import extensions, and the template `tsconfig.json` sets `allowImportingTsExtensions`. It also uses `optimizeDeps.rolldownOptions` instead of the deprecated `esbuildOptions`. New apps' root `package.json` overrides `postcss-selector-parser` to `^6.1.5`, which is what `@tailwindcss/typography` pulls in, as this repo does. An existing app can copy `host/client_app/vite.config.ts` and `tsconfig.json` from a new app, and add the override (#900).
- The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) generates now render inside the app shell: each sets `<Page>.layout = [AuthenticatedLayout]`. Before, they had no sidebar, header or footer, so the module's sidebar entry led to a bare page with no way back. To fix a module generated earlier, add `import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';` and that assignment to each of its pages, or use `AdminLayout` for pages you moved under `/admin` and `ADMIN_SIDEBAR` (#897).
- A fresh `smpy new` app passes its own `make lint`. `host/alembic.ini` gains a `[post_write_hooks]` entry that runs the project's ruff (`check --select I --fix`, then `format`) on every new revision, so the initial-schema revision and each `make migration` output no longer fail `ruff format --check` and `ruff check` (this repo's `host/alembic.ini` has it too); a standalone `smpy create-host` project, which has no workspace root, gets a `dev` dependency group with `ruff` for the hook. `create-module` sorts the `module.py` constants import for any module name (it was unsorted for names that sort before `group`, such as `bookmarks`). The `# ty: ignore[invalid-assignment]` in `contracts/schemas.py` is generated only for a module inside this repo, where ty checks that rule, and new apps pin `ty>=0.0.90,<0.0.100`, since ty 0.0.100 renamed the `unresolved-attribute` rule the scaffold's config names. The hooks use Alembic's `module` runner, so `simple_module_db` and the host template now require `alembic>=1.16.3`. An existing app can copy the `[post_write_hooks]` block from a new app's `host/alembic.ini` (and raise its own `alembic` requirement to `>=1.16.3`), drop the unused ignore comments and pin ty the same way (#896).
- `make doctor` (and `smpy doctor`) now runs the migration checks it documents. It reads `alembic_version` and the table list from `SM_DATABASE_URL` and the heads from the project's `alembic.ini`: `SM010` is an error when the database lacks any head and a warning when it was never migrated or could not be read, and `SM011` reports a module table that a database at every head does not have. A development boot reports `SM011` too once its database is at head (or once the setup wizard has migrated it), and the Doctor screen keeps it on re-run. Doctor now resolves a relative SQLite `SM_DATABASE_URL` against the project root as the app does, so run from `host/` it reads the app's database for `SM033` too. Before, neither code was ever reported. **Behaviour change:** `make doctor` now exits 1 against a dev database that is behind head; run `make migrate` (#895).
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ ci-check-untranslated:
ci-build-packages:
rm -rf dist-py-ci
uv build --all-packages --out-dir dist-py-ci
uv run python scripts/check_wheel_contents.py dist-py-ci
rm -rf dist-py-ci

# Diagnostics
Expand Down
4 changes: 3 additions & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ source prefixes for Vite's bare-import fallback and dependency scan. Existing
hosts must update that reader when adopting UI sources; upgrading Python
packages regenerates manifests but does not rewrite host-owned Vite config.
Wheel templates include UI sources and exclude `**/*.test.*`, `**/*.spec.*`,
`**/test-dom.tsx` and `**/page-tests/**` so old frontend test helpers do not ship.
`**/test-dom.tsx` and `**/page-tests/**` so old frontend test helpers do not
ship. `make ci-build-packages` fails when any built wheel (this repo's own modules included) still contains a JS or TS test file,
`test-dom.tsx` or a `page-tests/` directory (`scripts/check_wheel_contents.py`).
Keep CSS tokens in root `theme.css` and component rules in root `styles.css`.

The separate legacy `smpy module build` command still uses root `assets_src/`
Expand Down
11 changes: 11 additions & 0 deletions docs/superpowers/plans/2026-11-11-scaffold-vite8-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Plan: a fresh app's `make dev` runs clean (#900)

Design: `docs/superpowers/specs/2026-11-11-scaffold-vite8-clean-design.md`.

1. Move `validation-errors.test.ts` to `modules/settings/tests-js/`.
2. Add `scripts/check_wheel_contents.py` and its tests, and wire it into
`make ci-build-packages`.
3. Port the template's `vite.config.ts` and `tsconfig.json` to Vite 8, and update the #152
test.
4. Add `_APP_NPM_OVERRIDES` to the root `package.json` in both layouts, with tests.
5. Add a CHANGELOG entry, then run the real checks in a scaffolded app.
34 changes: 34 additions & 0 deletions docs/superpowers/specs/2026-11-11-scaffold-vite8-clean-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# A fresh app's `make dev` runs clean (#900)

## Goal and scope

A new `smpy new` app should print no Vite errors or warnings on `make dev`, and should
have no `npm audit` findings. Each problem below was reproduced on `main` in a scaffolded
app running Vite 8.3.7.

| Symptom | Cause | Fix |
|---|---|---|
| "Failed to run dependency scan … vitest" | the `simple_module_settings` wheel ships `ui/components/validation-errors.test.ts`; Vite's dep scan walks installed module packages | move the test to `modules/settings/tests-js/`, where Vitest's `include` actually collects it (it was never run before); `make ci-build-packages` runs `scripts/check_wheel_contents.py`, which fails on any JS or TS `*.test.*` or `*.spec.*` file (by basename, so `openapi.spec.json` passes), a `test-dom.tsx`, or a member under a `page-tests/` directory |
| native config loader warning: `__dirname`, extensionless `./module-assets` and `./vite.dev-url` | the template predates Vite 8 | use `import.meta.dirname` and `.ts` extensions, and set `allowImportingTsExtensions: true` in the template tsconfig; this is legal because the tsconfig has `noEmit`, and this repo's host does the same |
| `optimizeDeps.esbuildOptions` is deprecated | Vite 8 scans with Rolldown | `optimizeDeps.rolldownOptions.resolve.modules`, which this repo's host already uses for the #152 fallback |
| 3 moderate `npm audit` findings | `@tailwindcss/typography@0.5.20`, the latest release, pins `postcss-selector-parser@6.0.10` | the root `package.json` of new apps, in both layouts, overrides it to `^6.1.5`, as this repo's root does |

## Decisions made

- The wheel check runs on built wheels, in the `ci-build-packages` CI job, rather than as a
source-tree rule. It checks what actually ships, including future hatch config mistakes.
- The override value comes from one constant, `_APP_NPM_OVERRIDES`. A test keeps it equal
to this repo's own override.
- Existing apps are left alone; the CHANGELOG says what to copy.

## Testing

- `scripts/tests/test_check_wheel_contents.py`.
- `framework/cli/tests/test_cli_scaffold_vite8.py` covers the template config, the
overrides in both layouts, and their parity with this repo's override.
- The existing #152 test now asserts `rolldownOptions`.
- Real checks:
- `make ci-build-packages`: 23 clean wheels; `main`'s settings wheel fails the check;
- in a scaffolded app with the new template: Vite dev starts with no warnings (and no
scan error once the test file is gone), `vite build` and `tsc` pass, and a fresh
`npm install` gives `npm audit`: 0 vulnerabilities.
5 changes: 5 additions & 0 deletions framework/cli/simple_module_cli/_app_project_manifests.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
"vite": "^8.2.2",
}

# `@tailwindcss/typography` (pulled in by @simple-module-py/ui) pins
# postcss-selector-parser 6.0.10, which `npm audit` flags as a moderate ReDoS
# (GH #900). This repo's root package.json carries the same override.
_APP_NPM_OVERRIDES = {"postcss-selector-parser": "^6.1.5"}


def _db_url(db: str, db_name: str, *, flat: bool, password: str = "<POSTGRES_PASSWORD>") -> str:
if db == "postgres":
Expand Down
17 changes: 10 additions & 7 deletions framework/cli/simple_module_cli/app_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from simple_module_cli._app_project_manifests import (
_APP_NPM_DEPS,
_APP_NPM_DEV_DEPS,
_APP_NPM_OVERRIDES,
_APP_PY_DEV_DEPS,
_FRAMEWORK_VERSION,
_db_url,
Expand Down Expand Up @@ -160,19 +161,21 @@ def create_app_project(
)
host_pyproject.write_text(text, encoding="utf-8")

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"}
)
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")
# npm reads `overrides` only from the root package.json, in both layouts.
pkg_data.setdefault("overrides", {}).update(_APP_NPM_OVERRIDES)
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["vite/client", "node"],
"allowImportingTsExtensions": false
"allowImportingTsExtensions": true
},
"include": ["**/*.ts", "**/*.tsx"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { type Plugin, defineConfig } from 'vite';
import { loadModuleAssets } from './module-assets';
import { viteDevServer } from './vite.dev-url';
import { loadModuleAssets } from './module-assets.ts';
import { viteDevServer } from './vite.dev-url.ts';

// Force every importer (host, workspace module, wheel-installed module)
// to resolve to one React copy + a single Inertia hook context. Without
Expand All @@ -29,11 +29,11 @@ function findNodeModulesRoot(start: string): string {
}
return start;
}
const fsRoot = findNodeModulesRoot(__dirname);
const fsRoot = findNodeModulesRoot(import.meta.dirname);

// Walks up from client_app to the directory holding the project .env itself —
// fsRoot tracks node_modules, which (in flat mode) is NOT where .env lives.
const { origin: devUrl, port: devPort } = viteDevServer(__dirname);
const { origin: devUrl, port: devPort } = viteDevServer(import.meta.dirname);

const {
fsAllow: moduleFsAllow,
Expand All @@ -42,7 +42,7 @@ const {
pagesPrefixes: modulePagesPrefixes,
aliases: moduleAliases,
npmNames: moduleNpmNames,
} = loadModuleAssets(__dirname);
} = loadModuleAssets(import.meta.dirname);
const fakeWorkspaceImporter = path.join(fsRoot, 'package.json');

// CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority`
Expand Down Expand Up @@ -99,7 +99,7 @@ function collectOptimizeIncludes(): string[] {
'use-sync-external-store/shim/with-selector',
]);
const visited = new Set<string>();
const queue: string[] = [path.join(__dirname, 'package.json'), ...modulePkgJsonPaths];
const queue: string[] = [path.join(import.meta.dirname, 'package.json'), ...modulePkgJsonPaths];
while (queue.length > 0) {
const pkgJsonPath = queue.shift();
if (!pkgJsonPath || visited.has(pkgJsonPath)) continue;
Expand Down Expand Up @@ -177,7 +177,7 @@ function moduleBareImportResolver(): Plugin {

export default defineConfig({
plugins: [moduleBareImportResolver(), react(), tailwindcss()],
root: __dirname,
root: import.meta.dirname,
resolve: {
// `#module/<pkg>` -> that module's package directory. Optional sugar for
// hand-written imports; modules.generated.css does not rely on it.
Expand All @@ -187,22 +187,24 @@ export default defineConfig({
optimizeDeps: {
entries: ['main.tsx', 'pages/**/*.tsx', ...moduleOptimizeEntries],
include: collectOptimizeIncludes(),
// Vite's dep scanner runs esbuild against every entry above. When the
// Vite's dep scanner runs Rolldown against every entry above. When the
// entry sits outside the workspace (a wheel-installed module's
// pages/, or sometimes even a workspace module's pages/), esbuild's
// upward node_modules walk from the importer does not always reach
// the hoisted node_modules at fsRoot. Seeding it as NODE_PATH-style
// fallback ensures bare specifiers like `maplibre-gl` resolve during
// scan-imports. See GitHub issue #152.
esbuildOptions: {
nodePaths: [path.join(fsRoot, 'node_modules')],
// pages/, or sometimes even a workspace module's pages/), the upward
// node_modules walk from the importer does not always reach the
// hoisted node_modules at fsRoot. Adding it to the resolve search is
// a NODE_PATH-style fallback, so bare specifiers like `maplibre-gl`
// resolve during scan-imports. See GitHub issue #152.
rolldownOptions: {
resolve: {
modules: ['node_modules', path.join(fsRoot, 'node_modules')],
},
},
},
build: {
outDir: '../static/dist',
manifest: true,
rollupOptions: {
input: path.resolve(__dirname, 'main.tsx'),
input: path.resolve(import.meta.dirname, 'main.tsx'),
},
},
server: {
Expand Down
48 changes: 48 additions & 0 deletions framework/cli/tests/test_cli_scaffold_vite8.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""A fresh app's `make dev` runs without Vite 8 warnings or `npm audit` findings (GH #900)."""

from __future__ import annotations

import json
import re
from pathlib import Path

import pytest
from simple_module_cli import app_project
from simple_module_cli._app_project_manifests import _APP_NPM_OVERRIDES
from simple_module_cli.app_project import create_app_project

_CLIENT_APP = Path(app_project.__file__).parent / "templates" / "host" / "client_app"
_REPO_ROOT = Path(__file__).resolve().parents[3]


def test_vite_config_loads_under_the_native_config_loader() -> None:
"""Vite 8 warns about `__dirname` and extensionless relative imports."""
config = (_CLIENT_APP / "vite.config.ts").read_text(encoding="utf-8")
assert "__dirname" not in config
relative = re.findall(r"from '(\.[^']+)'", config)
assert relative, "expected the config's helper imports"
assert all(spec.endswith(".ts") for spec in relative), relative
tsconfig = (_CLIENT_APP / "tsconfig.json").read_text(encoding="utf-8")
assert '"allowImportingTsExtensions": true' in tsconfig


def test_vite_config_uses_rolldown_options() -> None:
"""`optimizeDeps.esbuildOptions` is deprecated since Vite moved to Rolldown."""
config = (_CLIENT_APP / "vite.config.ts").read_text(encoding="utf-8")
assert "esbuildOptions" not in config
assert "rolldownOptions" in config


@pytest.mark.parametrize("flat", [False, True])
def test_root_package_json_overrides_the_audited_dependency(tmp_path: Path, flat: bool) -> None:
target = tmp_path / "app"
create_app_project(target, name="app", db="sqlite", tenancy=False, selected=[], flat=flat)
pkg = json.loads((target / "package.json").read_text(encoding="utf-8"))
assert pkg["overrides"] == _APP_NPM_OVERRIDES


def test_app_overrides_match_this_repos() -> None:
"""The repo's own root package.json is what CI audits; keep the two in step."""
repo_pkg = json.loads((_REPO_ROOT / "package.json").read_text(encoding="utf-8"))
for name, spec in _APP_NPM_OVERRIDES.items():
assert repo_pkg["overrides"][name] == spec
19 changes: 9 additions & 10 deletions framework/cli/tests/test_scaffolding_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,8 @@ async def test_cli_create_host_runs_end_to_end(self, tmp_path):
)

async def test_scaffold_vite_config_includes_node_paths_fallback(self, tmp_path):
"""vite.config.ts must seed optimizeDeps.esbuildOptions.nodePaths
with the workspace node_modules so esbuild's scan-imports pass can
"""vite.config.ts must add to optimizeDeps.rolldownOptions.resolve.modules
with the workspace node_modules so the dep scanner's scan-imports pass can
resolve cross-package bare imports from module pages whose importers
sit outside the host's client_app (e.g. wheel-installed modules).

Expand All @@ -242,22 +242,21 @@ async def test_scaffold_vite_config_includes_node_paths_fallback(self, tmp_path)

# The scanner fallback must be configured under optimizeDeps so it
# applies to both dev pre-bundling and `vite build`'s pre-bundle pass.
assert "esbuildOptions" in vite_config, (
"vite.config.ts must configure optimizeDeps.esbuildOptions"
)
assert "nodePaths" in vite_config, (
"vite.config.ts must seed optimizeDeps.esbuildOptions.nodePaths "
"with the workspace node_modules (GH issue #152)."
# Vite 8 scans with Rolldown; `esbuildOptions` is deprecated (GH #900).
assert "rolldownOptions" in vite_config, "must configure optimizeDeps.rolldownOptions"
assert "modules: ['node_modules', path.join(fsRoot, 'node_modules')]" in vite_config, (
"vite.config.ts must add the workspace node_modules to "
"optimizeDeps.rolldownOptions.resolve.modules (GH issue #152)."
)
# The seeded path must reference fsRoot — the dir that contains the
# hoisted node_modules — not a hardcoded literal that breaks in
# flat-vs-workspace layouts.
assert "fsRoot" in vite_config, (
"nodePaths must reference fsRoot (not a hardcoded literal) so the "
"the resolve fallback must reference fsRoot (not a hardcoded literal) so the "
"fallback works in both flat and workspace layouts (GH issue #152)."
)
assert "node_modules" in vite_config, (
"nodePaths entry must include 'node_modules' (GH issue #152)."
"the resolve fallback must include 'node_modules' (GH issue #152)."
)

async def test_scaffold_vite_resolver_does_not_skip_workspace_modules(self, tmp_path):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { splitValidationErrors } from './validation-errors';
import { splitValidationErrors } from '../settings/ui/components/validation-errors';

describe('splitValidationErrors', () => {
it('maps located errors to their field', () => {
Expand All @@ -14,8 +14,13 @@ describe('splitValidationErrors', () => {
expect(r.form).toEqual(['timeout exceeds lease']);
});

it('tolerates a non-list detail', () => {
expect(splitValidationErrors('nope')).toEqual({ fields: {}, form: [] });
it('surfaces a string detail at form level, so a 422 is never swallowed', () => {
expect(splitValidationErrors('nope')).toEqual({ fields: {}, form: ['nope'] });
});

it('tolerates a detail that is neither a list nor a string', () => {
expect(splitValidationErrors(null)).toEqual({ fields: {}, form: [] });
expect(splitValidationErrors({ msg: 'x' })).toEqual({ fields: {}, form: [] });
});

it('maps a nested loc to its field with the path in the message', () => {
Expand Down
Loading
Loading