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

- `smpy new` warns when the CLI running it is older than the latest `simple_module_cli` on PyPI. A new app pins the framework to the CLI's own version, and `uvx --from simple_module_cli` quietly reuses a copy installed with `uv tool install`, so an old tool used to scaffold an outdated app without saying so. The warning names both fixes: `uv tool upgrade simple_module_cli`, or `uvx --from simple_module_cli@latest`. The check is best effort, with a 3-second timeout; offline or on any PyPI error it is skipped, and it never blocks the scaffold. A CLI that is itself a pre-release, dev or local build is never warned. The README, the CLI and hosting READMEs and the landing page's copy command now use `uvx --from simple_module_cli@latest`, and the installation and quickstart guides, the docs home and the make-commands reference say how to upgrade an installed CLI, with `pipx upgrade` as the alternative (#899).
- The first-module guidance now agrees with itself. The guides (`docs/index.md`, quickstart, installation, Your first module) wire a new module with `smpy add ../modules/orders --path host`, the command `smpy create-module` prints, which adds it to `host/pyproject.toml` as a workspace source. They used to say `uv add ./modules/orders`, which in a `smpy new` workspace edits the root `pyproject.toml` instead. A flat app uses `smpy add ./modules/orders`. The landing page `smpy new` generates drops the `create-admin` step (the `/setup` wizard already creates the first administrator), lists the wiring and migration steps after `create-module`, and its "Read the docs" button opens the first-module guide on docs.py.simplemodule.dev instead of the GitHub repo. The first-module guide and the `simple-module-conventions` skill note that SQLModel's `Field(regex=...)` is accepted but not enforced on a DTO, and show a `field_validator` instead (#898).
- 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).
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ A modular-monolith framework for Python. Each feature lives in its own self-cont
If you want to **build an app on simple_module**, not hack on the framework itself:

```bash
uvx --from simple_module_cli smpy new my-app
uvx --from simple_module_cli@latest smpy new my-app
cd my-app
make dev
```

`@latest` matters: without it `uvx` reuses an `smpy` you installed earlier with `uv tool install`, and an older CLI scaffolds an app pinned to its own, older framework version. `smpy new` warns when PyPI has a newer CLI.

That scaffolds a working FastAPI + Inertia + React app with `users`, `dashboard`, and `permissions` pre-wired. You land on `/users/login`, sign in with the admin account you bootstrap, and go from there.

See [CHANGELOG.md](CHANGELOG.md) for the list of published PyPI / npm packages at each release.
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ That puts `smpy` on your PATH globally. Confirm with:
smpy --help
```

Already installed? `uv tool upgrade simple_module_cli` (or `pipx upgrade simple_module_cli`). A new app pins the framework to the CLI's own version, so an old CLI scaffolds an old app; `smpy new` warns when PyPI has a newer one.

## Scaffold a new app

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Five minutes from `smpy new` to a running app with a freshly scaffolded module.
uv tool install simple_module_cli
```

(Or `pipx install simple_module_cli`.) That puts `smpy` on your PATH globally.
(Or `pipx install simple_module_cli`.) That puts `smpy` on your PATH globally. Already installed? `uv tool upgrade simple_module_cli` (or `pipx upgrade simple_module_cli`). A new app pins the framework to the CLI's own version, so an old CLI scaffolds an old app; `smpy new` warns when PyPI has a newer one.

## 2. Scaffold an app

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ features:
## Try it in 60 seconds

```bash
uv tool install simple_module_cli
uv tool install simple_module_cli # already installed? uv tool upgrade simple_module_cli
smpy new myapp --yes
cd myapp
make dev # API on :8000, Vite on :5050
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/make-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Two surfaces drive day-to-day work:

1. **The `smpy` CLI** — installed globally with `uv tool install simple_module_cli`. Used to scaffold apps and modules and to bump dependency versions.
1. **The `smpy` CLI** — installed globally with `uv tool install simple_module_cli` (upgrade with `uv tool upgrade simple_module_cli`). Used to scaffold apps and modules and to bump dependency versions.
2. **The Makefile in your scaffolded app** — a thin wrapper around `uv` and `npm` for the inner dev loop (`dev`, `migrate`, `build`, `gen-pages`).

Past those, there's no hidden tooling — read the `Makefile` and pyproject.toml in your app directly when something surprises you.
Expand Down
42 changes: 42 additions & 0 deletions docs/superpowers/specs/2026-11-11-stale-cli-warning-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Warn when `smpy new` runs from an outdated CLI (#899)

## Problem

A new app pins every `simple_module_*` package to the running CLI's own version
(`pins.resolve_framework_version`). `uvx --from simple_module_cli` reuses a tool that was
installed earlier with `uv tool install`, and `--refresh` doesn't change that. So an old
tool scaffolds an old app, and nothing says so.

## Design

- `simple_module_cli/_version_check.py`:
- `newer_release(current)` reuses `pypi.fetch_latest` with its own 3-second fetcher. It
returns the newer stable version, or `None`. It also returns `None` for the
`0.0.0` editable placeholder (`pins.PLACEHOLDER_VERSION`), for a running version that
is a pre-release, dev or local build (not what PyPI ships), and on any exception:
offline, timeouts, bad JSON or an unexpected payload shape. The 3-second fetcher is
`partial(pypi.default_fetcher, timeout=3)`.
- `warn_if_outdated` prints a warning to stderr that names `uv tool upgrade
simple_module_cli` (or `pipx upgrade`) and `uvx --from simple_module_cli@latest`. It never blocks the
scaffold.
- `smpy new` calls it right after validating the name.
- `framework/cli/tests/conftest.py` gets an autouse fixture that makes the fetcher raise,
so no CLI test touches the network.
- Docs:
- `README.md`, `framework/cli/README.md`, `framework/hosting/README.md` and the host
landing page copy command (`Landing.tsx`) use `@latest`;
- the installation and quickstart guides, `docs/index.md` and
`docs/reference/make-commands.md` say how to upgrade.

Rejected: an env var to opt out. The check is quiet whenever it can't run, and repo rules
discourage new env-var configuration.

## Testing

- Unit tests:
- a newer version is reported;
- the same version, a newer local one, the placeholder, and a pre-release, dev or local
running version are not;
- every failure type is skipped.
- CLI tests: `smpy new` warns and still scaffolds, and is silent when PyPI is unreachable.
- Against the real PyPI: `0.0.36` reports 0.0.44, and `0.0.44` reports nothing (0.16 s).
2 changes: 1 addition & 1 deletion framework/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pip install simple_module_cli
# or, to keep the CLI in its own venv:
pipx install simple_module_cli
# or, to run it without installing:
uvx --from simple_module_cli smpy new my-app
uvx --from simple_module_cli@latest smpy new my-app
```

The package depends only on `typer`, `tomlkit` and `packaging` — installing it does **not** pull in FastAPI, SQLModel, or any other framework runtime.
Expand Down
53 changes: 53 additions & 0 deletions framework/cli/simple_module_cli/_version_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Warn when ``smpy new`` runs from an older CLI than PyPI's latest (GH #899).

A new app pins every framework package to the running CLI's own version, and
``uvx --from simple_module_cli`` quietly reuses a ``uv tool install``-ed copy, so
an old tool scaffolds an old app without saying so. The check is best effort: a
short timeout, and any failure (offline, PyPI down, bad JSON) skips it.
"""

from __future__ import annotations

from functools import partial

import typer
from packaging.version import Version

from simple_module_cli.pins import PLACEHOLDER_VERSION
from simple_module_cli.pypi import PYPI_URL, Fetcher, default_fetcher, fetch_latest

CLI_DIST = "simple_module_cli"
_TIMEOUT_SECONDS = 3
_quick_fetcher: Fetcher = partial(default_fetcher, timeout=_TIMEOUT_SECONDS)


def newer_release(current: str, fetcher: Fetcher | None = None) -> str | None:
"""The latest stable ``simple_module_cli`` on PyPI if it is newer than ``current``."""
if current == PLACEHOLDER_VERSION:
return None
try:
running = Version(current)
if running.is_prerelease or running.is_devrelease or running.local:
return None # pre-release, dev or local build: not what PyPI ships
latest = fetch_latest(CLI_DIST, include_pre=False, fetcher=fetcher or _quick_fetcher)
if latest is not None and Version(latest) > running:
return latest
except Exception:
# OSError (timeouts, sockets), ValueError (bad JSON/versions), but also
# http.client.IncompleteRead and AttributeError on an unexpected payload shape.
return None
return None


def warn_if_outdated(current: str, fetcher: Fetcher | None = None) -> None:
latest = newer_release(current, fetcher)
if latest is None:
return
typer.echo(
f"WARNING: this smpy is {current}, but {CLI_DIST} {latest} is on PyPI "
f"({PYPI_URL.format(name=CLI_DIST)}). The new app will pin the framework to "
f"{current}.\n"
f" Upgrade the installed tool: uv tool upgrade {CLI_DIST} (pipx upgrade {CLI_DIST})\n"
f" or run the latest once: uvx --from {CLI_DIST}@latest smpy new ...",
err=True,
)
3 changes: 3 additions & 0 deletions framework/cli/simple_module_cli/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@

import typer

from simple_module_cli._version_check import warn_if_outdated
from simple_module_cli.add_cmd import run_add
from simple_module_cli.app_project import create_app_project
from simple_module_cli.case import InvalidScaffoldNameError, to_kebab_case, validate_scaffold_name
from simple_module_cli.catalog import PRESETS, expand_deps
from simple_module_cli.pins import resolve_framework_version
from simple_module_cli.wizard import run_wizard

__all__ = ["new_project"]
Expand Down Expand Up @@ -110,6 +112,7 @@ def new_project(
except InvalidScaffoldNameError as exc:
typer.echo(f"ERROR: {exc}", err=True)
raise typer.Exit(code=1) from exc
warn_if_outdated(resolve_framework_version())
pypi_name = to_kebab_case(name)
if pypi_name != name:
typer.echo(f"Normalizing PyPI name to {pypi_name!r}.")
Expand Down
5 changes: 4 additions & 1 deletion framework/cli/simple_module_cli/pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

from pathlib import Path

# What resolve_framework_version() returns for an install without dist-info.
PLACEHOLDER_VERSION = "0.0.0"


def resolve_framework_version() -> str:
"""Resolve the framework version that scaffolded apps should pin against.
Expand All @@ -31,7 +34,7 @@ def resolve_framework_version() -> str:
try:
return pkg_version("simple_module_cli")
except PackageNotFoundError:
return "0.0.0"
return PLACEHOLDER_VERSION


def _pin_one(dep: str, version: str) -> str:
Expand Down
4 changes: 2 additions & 2 deletions framework/cli/simple_module_cli/pypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,6 @@ def fetch_latest(name: str, *, include_pre: bool, fetcher: Fetcher) -> str | Non
return fallback if is_valid_version(fallback) else None


def default_fetcher(url: str) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=10) as resp:
def default_fetcher(url: str, timeout: float = 10) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
10 changes: 10 additions & 0 deletions framework/cli/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
import pytest


@pytest.fixture(autouse=True)
def _no_pypi_version_check(monkeypatch):
"""`smpy new` asks PyPI for a newer CLI (GH #899); keep CLI tests off the network."""

def offline(url: str) -> dict:
raise OSError(f"network disabled in tests: {url}")

monkeypatch.setattr("simple_module_cli._version_check._quick_fetcher", offline)


@pytest.fixture
def fake_pypi():
"""Return a factory building a `package-update` fetcher stub.
Expand Down
56 changes: 56 additions & 0 deletions framework/cli/tests/test_cli_version_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""`smpy new` warns when the running CLI is older than PyPI's latest (GH #899)."""

from __future__ import annotations

from pathlib import Path

import pytest
from simple_module_cli import _version_check
from simple_module_cli.cli import app
from typer.testing import CliRunner


def test_newer_release_reports_a_newer_stable_version(fake_pypi) -> None:
fetcher = fake_pypi({"simple_module_cli": "0.0.44"})
assert _version_check.newer_release("0.0.36", fetcher) == "0.0.44"


@pytest.mark.parametrize(
"current", ["0.0.44", "0.0.45", "0.0.0", "0.0.40.dev0", "0.0.40rc1", "0.0.40+local"]
)
def test_no_newer_release_when_current_or_placeholder(fake_pypi, current: str) -> None:
fetcher = fake_pypi({"simple_module_cli": "0.0.44"})
assert _version_check.newer_release(current, fetcher) is None


@pytest.mark.parametrize(
"error", [OSError("offline"), TimeoutError(), ValueError("bad json"), AttributeError("shape")]
)
def test_any_lookup_failure_skips_the_check(error: Exception) -> None:
def failing(url: str) -> dict:
raise error

assert _version_check.newer_release("0.0.36", failing) is None


def test_smpy_new_warns_and_still_scaffolds(tmp_path: Path, fake_pypi, monkeypatch) -> None:
monkeypatch.setattr(
_version_check, "_quick_fetcher", fake_pypi({"simple_module_cli": "99.0.0"})
)
target = tmp_path / "demo"
result = CliRunner().invoke(
app, ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)]
)
assert result.exit_code == 0, result.output
assert "simple_module_cli 99.0.0 is on PyPI" in result.output
assert "uv tool upgrade simple_module_cli" in result.output
assert (target / "host" / "pyproject.toml").is_file()


def test_smpy_new_is_silent_when_pypi_is_unreachable(tmp_path: Path) -> None:
target = tmp_path / "demo"
result = CliRunner().invoke(
app, ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)]
)
assert result.exit_code == 0, result.output
assert "WARNING: this smpy" not in result.output
2 changes: 1 addition & 1 deletion framework/hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pip install simple_module_hosting
For a new project, most users run the generator instead (shipped by the standalone `simple_module_cli` distribution):

```bash
uvx --from simple_module_cli smpy new my-app
uvx --from simple_module_cli@latest smpy new my-app
```

## What it provides
Expand Down
2 changes: 1 addition & 1 deletion host/client_app/components/CopyCommand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ vi.mock('@simple-module-py/i18n', () => ({

import { CopyCommand } from './CopyCommand';

const COMMAND = 'uvx --from simple_module_cli smpy new my-app';
const COMMAND = 'uvx --from simple_module_cli@latest smpy new my-app';

function stubClipboard(impl: () => Promise<void>) {
const writeText = vi.fn(impl);
Expand Down
2 changes: 1 addition & 1 deletion host/client_app/pages/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Database, LayoutTemplate, Package, Route, ShieldCheck, Stethoscope } fr
import { CopyCommand } from '../components/CopyCommand';

/** The one command the hero exists to hand over. */
const INSTALL_COMMAND = 'uvx --from simple_module_cli smpy new my-app';
const INSTALL_COMMAND = 'uvx --from simple_module_cli@latest smpy new my-app';

/**
* The terminal transcript, one entry per numbered step.
Expand Down
Loading