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
7 changes: 6 additions & 1 deletion framework/cli/simple_module_cli/app_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def create_app_project(
tenancy: bool = False,
selected: Sequence[str] | None = None,
flat: bool = False,
) -> None:
) -> Path:
"""Greenfield ``simple-module new`` scaffold.

In workspace mode (the default), lays down a uv + npm workspace at
Expand All @@ -110,6 +110,9 @@ def create_app_project(
``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).

Returns the host directory — ``target`` in flat mode, ``target/host``
in workspace mode.
"""
if target.exists() and any(target.iterdir()):
raise FileExistsError(
Expand Down Expand Up @@ -177,6 +180,8 @@ def create_app_project(
if recipe_key is not None and recipe_key in RECIPES:
RECIPES[recipe_key].apply(target, ctx)

return host_dir


def _strip_workspace_owned_files(host_dir: Path) -> None:
"""Drop host copies of files the workspace root owns in workspace mode."""
Expand Down
28 changes: 24 additions & 4 deletions framework/cli/simple_module_cli/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

__all__ = ["new_project"]

_ALEMBIC = ("uv", "run", "alembic")


class Db(StrEnum):
sqlite = "sqlite"
Expand Down Expand Up @@ -62,7 +64,7 @@ def new_project(
bool,
typer.Option(
"--no-install",
help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.",
help=("Skip 'uv sync' / 'npm install' / initial alembic migration after scaffolding."),
),
] = False,
flat: Annotated[
Expand Down Expand Up @@ -109,7 +111,7 @@ def new_project(
raise typer.Exit(code=1) from None

try:
create_app_project(
host_dir = create_app_project(
target,
name=name,
db=db_final,
Expand All @@ -128,7 +130,8 @@ def new_project(
if no_install:
typer.echo(" uv sync")
typer.echo(" npm install")
typer.echo(" alembic upgrade head")
typer.echo(' make migration msg="initial schema"')
typer.echo(" make migrate")
typer.echo(" make dev")
if "background_tasks" in resolved:
typer.echo(" docker compose up -d redis worker beat # background jobs")
Expand All @@ -152,7 +155,24 @@ def new_project(
)
return

subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False)
_bootstrap_initial_migration(host_dir)
subprocess.run([*_ALEMBIC, "upgrade", "head"], cwd=host_dir, check=False)
typer.echo("\nSetup complete. Run `make dev` in the new directory.")
if "background_tasks" in resolved:
typer.echo("For background jobs, also run: docker compose up -d redis worker beat")


def _bootstrap_initial_migration(host_dir: Path) -> None:
"""Autogenerate the baseline migration if the scaffold ships none.

Without a real revision, ``alembic upgrade head`` is a silent no-op
against an empty schema — the bundled modules' tables never exist.
"""
versions_dir = host_dir / "migrations" / "versions"
if any(p.name != "__init__.py" for p in versions_dir.glob("*.py")):
return
subprocess.run(
[*_ALEMBIC, "revision", "--autogenerate", "-m", "initial schema"],
cwd=host_dir,
check=False,
)
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ make install
cp .env.example .env

# Apply DB migrations
# (`smpy new` already generated the initial migration for you; if you started
# from a bare host without one, run `make migration msg="initial schema"` first.)
make migrate

# Run API + UI together
Expand Down
63 changes: 63 additions & 0 deletions framework/cli/tests/test_cli_new_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,66 @@ def test_sm_new_rejects_mixed_separators(tmp_path: Path) -> None:
["new", "foo_bar-baz", "--yes", "--no-install", "--dest", str(tmp_path / "out")],
)
assert result.exit_code != 0


def test_sm_new_no_install_next_steps_include_initial_migration(tmp_path: Path) -> None:
"""Issue #135: ``host/migrations/versions/`` ships empty, so a fresh
``make migrate`` is a no-op. The printed next-steps must therefore
guide ``--no-install`` users to generate the baseline migration first."""
runner = CliRunner()
target = tmp_path / "demo"
result = runner.invoke(
app,
["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)],
)
assert result.exit_code == 0, result.output
assert 'make migration msg="initial schema"' in result.output
assert "make migrate" in result.output


def test_bootstrap_initial_migration_runs_autogenerate_when_versions_empty(
tmp_path: Path, monkeypatch
) -> None:
"""Issue #135: the post-install hook must call ``alembic revision
--autogenerate`` when ``migrations/versions/`` holds only ``.gitkeep``."""
from simple_module_cli import new as new_mod

host = tmp_path / "host"
(host / "migrations" / "versions").mkdir(parents=True)
(host / "migrations" / "versions" / ".gitkeep").touch()

calls: list[tuple[list[str], Path]] = []

def fake_run(cmd, *, cwd, check):
del check
calls.append((list(cmd), Path(cwd)))

class _Result:
returncode = 0

return _Result()

monkeypatch.setattr(new_mod.subprocess, "run", fake_run)
new_mod._bootstrap_initial_migration(host)
assert calls, "expected alembic autogenerate to run"
cmd, cwd = calls[0]
assert cmd[:5] == ["uv", "run", "alembic", "revision", "--autogenerate"]
assert cwd == host


def test_bootstrap_initial_migration_skips_when_revision_exists(
tmp_path: Path, monkeypatch
) -> None:
"""If the user has already run ``make migration``, don't clobber their
revision by autogenerating a second baseline."""
from simple_module_cli import new as new_mod

host = tmp_path / "host"
(host / "migrations" / "versions").mkdir(parents=True)
(host / "migrations" / "versions" / "0001_initial.py").write_text("# revision\n")

def fake_run(*_a, **_kw): # pragma: no cover - must not be called
raise AssertionError("alembic should not be invoked when a revision exists")

monkeypatch.setattr(new_mod.subprocess, "run", fake_run)
new_mod._bootstrap_initial_migration(host)
Loading