diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index e179298f..2c874f95 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -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 @@ -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( @@ -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.""" diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py index b9ff336a..2c56c6d2 100644 --- a/framework/cli/simple_module_cli/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -17,6 +17,8 @@ __all__ = ["new_project"] +_ALEMBIC = ("uv", "run", "alembic") + class Db(StrEnum): sqlite = "sqlite" @@ -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[ @@ -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, @@ -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") @@ -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, + ) diff --git a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl index f8656f01..cffea9b4 100644 --- a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl @@ -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 diff --git a/framework/cli/tests/test_cli_new_regressions.py b/framework/cli/tests/test_cli_new_regressions.py index 588ae4f9..d5812609 100644 --- a/framework/cli/tests/test_cli_new_regressions.py +++ b/framework/cli/tests/test_cli_new_regressions.py @@ -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)