diff --git a/.gitignore b/.gitignore index 1e7a499..bca07b6 100644 --- a/.gitignore +++ b/.gitignore @@ -227,3 +227,4 @@ REVIEW.md # Claude local settings .claude/settings.local.json +.mimocode diff --git a/README.md b/README.md index ee54c48..58a9f14 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,11 @@ You'll be guided through: 2. Package manager (`uv` / `pip`) 3. Framework (FastAPI / Flask / Django REST Framework) 4. Database (PostgreSQL+SQLite / MongoDB / both) -5. Authentication (JWT / none) -6. Optional `name` field on the `User` model -7. AI-assistant config (Claude / OpenAI / none) -8. Git initialization +5. Database name / user / password (defaults: slug / slug / auto-generated) +6. Authentication (JWT / none) +7. Optional `name` field on the `User` model +8. AI-assistant config (Claude / OpenAI / none) +9. Git initialization ### Non-interactive flags @@ -78,6 +79,9 @@ backendctl new myapi --framework fastapi --db postgres --pm uv --no-ai | `project_name` | string | Name of the new project (positional). | | `--framework`, `-f` | `fastapi` \| `flask` \| `django` | Web framework. | | `--db` | `postgres` \| `mongodb` \| `both` | Database. | +| `--db-name` | string | Database name (default: project slug). | +| `--db-user` | string | PostgreSQL user (default: project slug). | +| `--db-password` | string | PostgreSQL password (default: auto-generated). | | `--pm` | `uv` \| `pip` | Package manager. | | `--no-git` | flag | Skip git initialization. | | `--no-ai` | flag | Skip AI-assistant setup. | @@ -86,11 +90,13 @@ backendctl new myapi --framework fastapi --db postgres --pm uv --no-ai ### What you get After generation, `backendctl` runs pre-flight checks, writes the project, sets up -git (optional), and installs dependencies. Then: +git (optional), and installs dependencies. A `docker-compose.yml` is generated for +your chosen database, and `.env` ships with working credentials that already match +it — no manual copying or editing required to get a database running. Then: ```bash cd myapi -cp .env.example .env # fill in your secrets +docker compose up -d # start the database uv run fastapi dev # or: flask run / python manage.py runserver ``` diff --git a/SECURITY.md b/SECURITY.md index 6e48bf2..2857516 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ latest released version on PyPI. | Version | Supported | |---------|-----------| -| latest `0.1.x` | ✅ | +| latest `0.3.x` | ✅ | | older | ❌ | ## Reporting a vulnerability diff --git a/docs/superpowers/plans/2026-07-14-dynamic-db-config.md b/docs/superpowers/plans/2026-07-14-dynamic-db-config.md new file mode 100644 index 0000000..30f0097 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-dynamic-db-config.md @@ -0,0 +1,944 @@ +# Dynamic DB Config + Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wizard/flag-driven database credentials that flow into generated `.env` and a new `docker-compose.yml`, plus fixes for the six bugs confirmed in the 2026-07-14 review. + +**Architecture:** A `DatabaseCredentials` dataclass on `ProjectConfig` is resolved (defaults filled) once in `BaseGenerator.__init__`; template functions read the resolved values. `.env.example` always gets a password placeholder; `.env` gets the real password. `docker-compose.yml` is a new common template. + +**Tech Stack:** Python 3.11+, typer, questionary, rich, pytest. Spec: `docs/superpowers/specs/2026-07-14-dynamic-db-config-design.md`. + +## Global Constraints + +- Repo root: `/Users/dipto/My_Works/Projects/Backend setup cli tool` (note: path contains spaces — always quote it). +- Run tests with `uv run pytest -q`, lint with `uv run ruff check src tests`. +- DB identifier validation regex (names and users): `^[a-zA-Z_][a-zA-Z0-9_]*$`. +- Password placeholder literal (committed files only): `change-me-db-password`. +- The real password must NEVER appear in `.env.example`, `README.md`, or any committed template output. It may appear in `.env` and `docker-compose.yml` (both gitignored? compose is committed — password there is acceptable per spec since compose is the local-dev database). +- Auto-generated password: `secrets.token_urlsafe(16)`. +- Mongo runs unauthenticated at `localhost:27017`; only its db name is configurable. +- URL schemes: FastAPI/Flask `postgresql+psycopg`, Django `postgres`. +- Commit after every task with a conventional-commit message. Work happens on branch `feat/dynamic-db-config`. + +--- + +### Task 1: `DatabaseCredentials` model + +**Files:** +- Modify: `src/backendctl/core/config.py` +- Test: `tests/test_config.py` + +**Interfaces:** +- Produces: `DatabaseCredentials` dataclass with fields `db_name: str = ""`, `db_user: str = ""`, `db_password: str = ""`, `host: str = "localhost"`, `port: int = 5432`; methods `resolve(slug: str) -> None` (fills defaults, raises `ValueError` on invalid name/user) and `url(scheme: str, password: str | None = None) -> str`. Also `ProjectConfig.db_credentials: DatabaseCredentials` and module-level `DB_IDENT_RE`. + +- [ ] **Step 1: Write the failing tests** — append to `tests/test_config.py`: + +```python +import pytest + +from backendctl.core.config import DatabaseCredentials, ProjectConfig + + +def test_credentials_resolve_defaults_to_slug(): + creds = DatabaseCredentials() + creds.resolve("my_app") + assert creds.db_name == "my_app" + assert creds.db_user == "my_app" + assert len(creds.db_password) >= 16 # auto-generated + + +def test_credentials_resolve_keeps_explicit_values(): + creds = DatabaseCredentials(db_name="mydb", db_user="alice", db_password="pw") + creds.resolve("my_app") + assert (creds.db_name, creds.db_user, creds.db_password) == ("mydb", "alice", "pw") + + +@pytest.mark.parametrize("bad", ["1db", "my-db", "my db", "db;drop"]) +def test_credentials_resolve_rejects_invalid_identifiers(bad): + creds = DatabaseCredentials(db_name=bad) + with pytest.raises(ValueError): + creds.resolve("my_app") + + +def test_credentials_url_builds_and_percent_encodes(): + creds = DatabaseCredentials(db_name="mydb", db_user="alice", db_password="p@ss:word") + creds.resolve("x") + assert creds.url("postgresql+psycopg") == ( + "postgresql+psycopg://alice:p%40ss%3Aword@localhost:5432/mydb" + ) + # explicit password override (used for .env.example) + assert creds.url("postgres", password="change-me-db-password").endswith( + "://alice:change-me-db-password@localhost:5432/mydb" + ) + + +def test_project_config_has_credentials(): + assert isinstance(ProjectConfig().db_credentials, DatabaseCredentials) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_config.py -q` +Expected: FAIL / ERROR with `ImportError: cannot import name 'DatabaseCredentials'` + +- [ ] **Step 3: Implement** — in `src/backendctl/core/config.py`, add imports and the dataclass (above `ProjectConfig`), and the field on `ProjectConfig`: + +```python +import re +import secrets +from urllib.parse import quote + +# Safe for POSTGRES_DB/POSTGRES_USER and for URLs without quoting. +DB_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +@dataclass +class DatabaseCredentials: + """PostgreSQL credentials (Mongo shares db_name; runs unauthenticated locally).""" + + db_name: str = "" # resolves to project slug + db_user: str = "" # resolves to project slug + db_password: str = "" # resolves to a random token + host: str = "localhost" + port: int = 5432 + + def resolve(self, slug: str) -> None: + """Fill unset fields with defaults; validate identifiers.""" + self.db_name = (self.db_name or slug).strip() + self.db_user = (self.db_user or slug).strip() + for label, value in (("database name", self.db_name), ("database user", self.db_user)): + if not DB_IDENT_RE.match(value): + raise ValueError( + f"Invalid {label} {value!r}: use only letters, digits, and " + "underscores; must not start with a digit." + ) + if not self.db_password: + self.db_password = secrets.token_urlsafe(16) + + def url(self, scheme: str, password: str | None = None) -> str: + pw = quote(self.db_password if password is None else password, safe="") + return f"{scheme}://{self.db_user}:{pw}@{self.host}:{self.port}/{self.db_name}" +``` + +On `ProjectConfig`, after the `ai` field: + +```python + db_credentials: DatabaseCredentials = field(default_factory=DatabaseCredentials) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_config.py -q` → PASS. Then `uv run pytest -q` (whole suite) → all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/backendctl/core/config.py tests/test_config.py +git commit -m "feat: add DatabaseCredentials config model" +``` + +--- + +### Task 2: Resolve credentials in BaseGenerator; wire into env templates (incl. mongo-only sqlite fallback) + +**Files:** +- Modify: `src/backendctl/generators/base.py` (init), `src/backendctl/generators/fastapi_gen.py`, `src/backendctl/generators/flask_gen.py`, `src/backendctl/generators/django_gen.py` (env writes), `src/backendctl/templates/common.py` (placeholder constant), `src/backendctl/templates/fastapi.py`, `src/backendctl/templates/flask.py`, `src/backendctl/templates/django.py` (env_example functions) +- Test: `tests/test_generators.py` + +**Interfaces:** +- Consumes: `DatabaseCredentials.resolve/url` from Task 1. +- Produces: `templates.common.DB_PASSWORD_PLACEHOLDER = "change-me-db-password"`; every framework's `env_example(c, db_password: str | None = None)` — `None` means "use the placeholder". Generators resolved credentials are available to all templates as `c.db_credentials`. + +- [ ] **Step 1: Write the failing tests** — append to `tests/test_generators.py`: + +```python +def test_env_gets_real_credentials_example_gets_placeholder( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI) + config.db_credentials.db_name = "mydb" + config.db_credentials.db_user = "alice" + config.db_credentials.db_password = "s3cretpw" + + root = get_generator(config).generate() + + env = (root / ".env").read_text() + example = (root / ".env.example").read_text() + assert "postgresql+psycopg://alice:s3cretpw@localhost:5432/mydb" in env + assert "s3cretpw" not in example + assert "alice:change-me-db-password@localhost:5432/mydb" in example + + +def test_credentials_default_to_slug(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FLASK) + + root = get_generator(config).generate() + + assert "://demo_app:" in (root / ".env").read_text() + assert "/demo_app" in (root / ".env").read_text() + + +def test_django_env_uses_postgres_scheme(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.DJANGO) + config.db_credentials.db_password = "djpw" + + root = get_generator(config).generate() + + assert "DATABASE_URL=postgres://demo_app:djpw@localhost:5432/demo_app" in ( + root / ".env" + ).read_text() + + +def test_mongo_only_falls_back_to_sqlite( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + for framework in (Framework.FASTAPI, Framework.FLASK): + config = _make_config(framework, database=Database.MONGODB) + config.name = f"demo_{framework.value}" + root = get_generator(config).generate() + env = (root / ".env").read_text() + assert "sqlite:///" in env, framework + assert "postgresql+psycopg" not in env, framework + assert "psycopg" not in (root / "pyproject.toml").read_text(), framework + + +def test_mongo_db_name_flows_into_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI, database=Database.MONGODB) + config.db_credentials.db_name = "appdata" + + root = get_generator(config).generate() + + assert "MONGODB_DB_NAME=appdata" in (root / ".env").read_text() + + +def test_invalid_credentials_raise_scaffold_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI) + config.db_credentials.db_user = "bad;user" + + with pytest.raises(base.ScaffoldError): + get_generator(config) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_generators.py -q` — the six new tests FAIL (old URLs / no resolution). + +- [ ] **Step 3: Implement.** + +`src/backendctl/generators/base.py` — at the end of `__init__` (after the parent-dir check): + +```python + try: + config.db_credentials.resolve(config.slug) + except ValueError as exc: + raise ScaffoldError(str(exc)) from exc +``` + +`src/backendctl/templates/common.py` — add near the top: + +```python +DB_PASSWORD_PLACEHOLDER = "change-me-db-password" +``` + +`src/backendctl/templates/fastapi.py` — replace `env_example` signature/body: + +```python +def env_example(c: ProjectConfig, db_password: str | None = None) -> str: + from backendctl.templates.common import DB_PASSWORD_PLACEHOLDER + + creds = c.db_credentials + mongo_line = ( + f"\nMONGODB_URL=mongodb://localhost:27017\nMONGODB_DB_NAME={creds.db_name}\n" + if c.uses_mongo + else "" + ) + if c.uses_sql: + db_url = creds.url("postgresql+psycopg", password=db_password or DB_PASSWORD_PLACEHOLDER) + else: + # No PostgreSQL selected: auth/user data lives in SQLite (no extra driver). + db_url = "sqlite:///./app.db" +``` + +…and in the returned f-string replace the hardcoded line +`DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/{c.slug}` with `DATABASE_URL={db_url}`. + +`src/backendctl/templates/flask.py` — same pattern: + +```python +def env_example(c: ProjectConfig, db_password: str | None = None) -> str: + from backendctl.templates.common import DB_PASSWORD_PLACEHOLDER + + creds = c.db_credentials + mongo_block = ( + f"\n# MongoDB\nMONGO_URI=mongodb://localhost:27017/{creds.db_name}\n" + if c.uses_mongo + else "" + ) + if c.uses_sql: + db_url = creds.url("postgresql+psycopg", password=db_password or DB_PASSWORD_PLACEHOLDER) + else: + db_url = "sqlite:///app.db" +``` + +…and `DATABASE_URL={db_url}` in the body. + +`src/backendctl/templates/django.py` — same pattern (Django always uses SQL): + +```python +def env_example(c: ProjectConfig, db_password: str | None = None) -> str: + from backendctl.templates.common import DB_PASSWORD_PLACEHOLDER + + db_url = c.db_credentials.url("postgres", password=db_password or DB_PASSWORD_PLACEHOLDER) +``` + +…and `DATABASE_URL={db_url}` in the body (delete the old `pg = ...` line). + +Generators — pass the real password when writing `.env` (keep the existing secret-key replaces): + +`fastapi_gen.py`: + +```python + self._write(".env.example", t.env_example(c)) + self._write_if_absent( + ".env", + t.env_example(c, db_password=c.db_credentials.db_password).replace( + "change-me-to-a-long-random-string", _random_key() + ), + ) +``` + +`flask_gen.py`: + +```python + self._write(".env.example", t.env_example(c)) + self._write_if_absent( + ".env", + t.env_example(c, db_password=c.db_credentials.db_password) + .replace("change-me-to-a-long-random-string", secrets.token_hex(32)) + .replace("another-long-random-secret", secrets.token_hex(32)), + ) +``` + +`django_gen.py`: + +```python + self._write(".env.example", t.env_example(c)) + self._write_if_absent( + ".env", + t.env_example(c, db_password=c.db_credentials.db_password).replace( + "change-me-to-a-long-random-string", secrets.token_hex(32) + ), + ) +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` → all pass (matrix compile tests cover the changed templates). + +- [ ] **Step 5: Commit** + +```bash +git add -A src tests +git commit -m "feat: wire database credentials into generated .env files + +Mongo-only projects fall back to SQLite for the SQL layer instead of +shipping a postgres URL without the psycopg driver." +``` + +--- + +### Task 3: docker-compose.yml template + generated README quickstart + +**Files:** +- Modify: `src/backendctl/templates/common.py` (new `docker_compose()`, README changes), `src/backendctl/generators/base.py` (`_write_common_files`) +- Test: `tests/test_generators.py` + +**Interfaces:** +- Consumes: resolved `c.db_credentials` (Task 2). +- Produces: `templates.common.docker_compose(c: ProjectConfig) -> str`; `docker-compose.yml` written by `_write_common_files`. + +- [ ] **Step 1: Write the failing tests** — append to `tests/test_generators.py`: + +```python +def test_compose_postgres_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI) + config.db_credentials.db_password = "pgpw" + + root = get_generator(config).generate() + + compose = (root / "docker-compose.yml").read_text() + assert "postgres:16-alpine" in compose + assert "POSTGRES_DB: \"demo_app\"" in compose + assert "POSTGRES_PASSWORD: \"pgpw\"" in compose + assert "mongo" not in compose + + +def test_compose_mongo_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI, database=Database.MONGODB) + + root = get_generator(config).generate() + + compose = (root / "docker-compose.yml").read_text() + assert "mongo:7" in compose + assert "postgres" not in compose + + +def test_compose_both_and_readme_mentions_compose( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FLASK, database=Database.BOTH) + + root = get_generator(config).generate() + + compose = (root / "docker-compose.yml").read_text() + assert "postgres:16-alpine" in compose and "mongo:7" in compose + assert "docker compose up -d" in (root / "README.md").read_text() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_generators.py -q` — new tests FAIL (`docker-compose.yml` missing). + +- [ ] **Step 3: Implement.** + +`src/backendctl/templates/common.py` — add (uses `json.dumps` so any password is a valid YAML scalar): + +```python +import json + + +def docker_compose(c: ProjectConfig) -> str: + creds = c.db_credentials + services: list[str] = [] + volumes: list[str] = [] + + if c.uses_sql: + services.append( + f"""\ + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: {json.dumps(creds.db_name)} + POSTGRES_USER: {json.dumps(creds.db_user)} + POSTGRES_PASSWORD: {json.dumps(creds.db_password)} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {creds.db_user} -d {creds.db_name}"] + interval: 5s + timeout: 3s + retries: 10 +""" + ) + volumes.append(" postgres_data:") + + if c.uses_mongo: + services.append( + """\ + mongo: + image: mongo:7 + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db +""" + ) + volumes.append(" mongo_data:") + + return "services:\n" + "\n".join(services) + "\nvolumes:\n" + "\n".join(volumes) + "\n" +``` + +`src/backendctl/generators/base.py` — in `_write_common_files`, import `docker_compose` alongside the others and add before `print_info`: + +```python + self._write("docker-compose.yml", docker_compose(self.config)) +``` + +(Every `Database` value uses at least one engine, so it is always written.) + +`common.py` `readme()` — in the Quickstart block, insert between step 2 (env) and the migrations step: + +``` +# 3. Start the database (Docker) +docker compose up -d +``` + +renumber migrations to 4 and dev server to 5. Also append to the mongo case: inside `readme()`, after the Quickstart block, add when `c.uses_mongo and not c.uses_sql`: + +```python + mongo_note = ( + "\n> **Note:** auth/user data is stored in SQLite (`app.db`); MongoDB is wired " + "for application data.\n" + if c.uses_mongo and not c.uses_sql + else "" + ) +``` + +and interpolate `{mongo_note}` right after the Quickstart code fence. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` → all pass. Sanity: `uv run python -c "from backendctl.core.config import ProjectConfig; from backendctl.templates.common import docker_compose; c=ProjectConfig(name='x'); c.db_credentials.resolve(c.slug); print(docker_compose(c))"` prints valid YAML. + +- [ ] **Step 5: Commit** + +```bash +git add -A src tests +git commit -m "feat: generate docker-compose.yml for the selected database" +``` + +--- + +### Task 4: Wizard prompts + CLI flags for credentials + +**Files:** +- Modify: `src/backendctl/cli/new.py` +- Test: `tests/test_cli.py` + +**Interfaces:** +- Consumes: `DB_IDENT_RE` from `core.config` (Task 1); `provided` set mechanism already in `new_command`. +- Produces: flags `--db-name`, `--db-user`, `--db-password`; wizard prompts after the database step; provided-keys `"db_name"`, `"db_user"`, `"db_password"`. + +- [ ] **Step 1: Write the failing tests** — append to `tests/test_cli.py`: + +```python +def _stub_generation(monkeypatch): + from backendctl.generators import base + + monkeypatch.setattr(base.BaseGenerator, "_install_deps", lambda self: None) + monkeypatch.setattr(base.BaseGenerator, "_git_init", lambda self: None) + monkeypatch.setattr("backendctl.cli.new.run_preflight", lambda *a, **k: True) + + +def test_db_flags_flow_into_generated_env(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke( + app, + [ + "new", "demo", "--framework", "fastapi", "--db", "postgres", + "--db-name", "customdb", "--db-user", "alice", "--db-password", "pw12345", + "--yes", "--no-git", "--no-ai", + ], + ) + + assert result.exit_code == 0, result.output + env = (tmp_path / "demo" / ".env").read_text() + assert "postgresql+psycopg://alice:pw12345@localhost:5432/customdb" in env + + +@pytest.mark.parametrize("flag", ["--db-name", "--db-user"]) +def test_invalid_db_identifier_flags_rejected(flag: str, tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke(app, ["new", "demo", flag, "bad;name", "--yes", "--no-git", "--no-ai"]) + + assert result.exit_code == 1 + assert not (tmp_path / "demo").exists() + + +def test_yes_generates_random_password(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke(app, ["new", "demo", "--yes", "--no-git", "--no-ai"]) + + assert result.exit_code == 0, result.output + env = (tmp_path / "demo" / ".env").read_text() + assert "change-me-db-password" not in env + assert "user:password@" not in env +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_cli.py -q` — FAIL (`No such option: --db-name`; the `--yes` test fails on the old placeholder URL only if Task 2 missed it — it should already pass; keep it as a regression guard). + +- [ ] **Step 3: Implement** in `src/backendctl/cli/new.py`. + +Import `DB_IDENT_RE` in the existing `core.config` import block. Add helpers next to `_validate_project_name`: + +```python +def _validate_db_identifier(value: str) -> bool | str: + if not value.strip(): + return "Value cannot be empty." + if not DB_IDENT_RE.match(value.strip()): + return "Use only letters, digits, and underscores; must not start with a digit." + return True +``` + +Add a wizard step function (below `_run_wizard`'s step 5, called from inside `_run_wizard` right after the database step): + +```python +def _ask_db_credentials(config: ProjectConfig, provided: set[str], assume_yes: bool) -> None: + """Prompt for DB name/user/password. Unset fields resolve to defaults later.""" + if assume_yes: + return + if "db_name" not in provided: + config.db_credentials.db_name = _ask( + questionary.text, + message="Database name:", + default=config.slug, + validate=_validate_db_identifier, + ).strip() + if config.uses_sql: + if "db_user" not in provided: + config.db_credentials.db_user = _ask( + questionary.text, + message="Database user:", + default=config.slug, + validate=_validate_db_identifier, + ).strip() + if "db_password" not in provided: + config.db_credentials.db_password = _ask( + questionary.password, + message="Database password (leave empty to auto-generate):", + ) +``` + +In `_run_wizard`, after the `# 5. Database` block ends, add: + +```python + # 5b. Database credentials + _ask_db_credentials(config, provided, assume_yes) +``` + +In `new_command`, add the three options (after the `ai` option): + +```python + db_name: Optional[str] = typer.Option( + None, "--db-name", help="Database name (default: project slug)." + ), + db_user: Optional[str] = typer.Option( + None, "--db-user", help="PostgreSQL user (default: project slug)." + ), + db_password: Optional[str] = typer.Option( + None, "--db-password", help="PostgreSQL password (default: auto-generated)." + ), +``` + +…and in the flag-seeding section (after the `auth` block): + +```python + for flag, field_name, value in ( + ("--db-name", "db_name", db_name), + ("--db-user", "db_user", db_user), + ): + if value: + check = _validate_db_identifier(value) + if check is not True: + print_error(f"Invalid {flag} '{value}': {check}") + raise typer.Exit(1) + setattr(config.db_credentials, field_name, value.strip()) + provided.add(field_name) + if db_password: + config.db_credentials.db_password = db_password + provided.add("db_password") +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` → all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/backendctl/cli/new.py tests/test_cli.py +git commit -m "feat: add --db-name/--db-user/--db-password flags and wizard prompts" +``` + +--- + +### Task 5: Django fixes — migrations packages + SQLite test settings + +**Files:** +- Modify: `src/backendctl/generators/django_gen.py`, `src/backendctl/templates/django.py` +- Test: `tests/test_generators.py` + +**Interfaces:** +- Produces: `templates.django.settings_test() -> str`; generated files `apps/users/migrations/__init__.py`, `apps/authentication/migrations/__init__.py`, `config/settings/test.py`; pytest settings module `config.settings.test`. + +- [ ] **Step 1: Write the failing tests** — append to `tests/test_generators.py`: + +```python +def test_django_migrations_packages_exist( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.DJANGO)).generate() + + assert (root / "apps/users/migrations/__init__.py").is_file() + assert (root / "apps/authentication/migrations/__init__.py").is_file() + + +def test_django_pytest_uses_sqlite_test_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.DJANGO)).generate() + + assert 'DJANGO_SETTINGS_MODULE = "config.settings.test"' in ( + root / "pyproject.toml" + ).read_text() + test_settings = (root / "config/settings/test.py").read_text() + assert "TEST_DATABASE_URL" in test_settings + assert "sqlite" in test_settings +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_generators.py -q` — both FAIL. + +- [ ] **Step 3: Implement.** + +`src/backendctl/templates/django.py`: +- In `pyproject_toml`, change `DJANGO_SETTINGS_MODULE = "config.settings.development"` to `DJANGO_SETTINGS_MODULE = "config.settings.test"`. +- Add after `settings_production()`: + +```python +def settings_test() -> str: + return """\ +from config.settings.base import * # noqa: F401, F403 +from config.settings.base import env + +DEBUG = True + +# Tests run on SQLite by default — no PostgreSQL server required. +DATABASES = {"default": env.db("TEST_DATABASE_URL", default="sqlite:///test.db")} +""" +``` + +`src/backendctl/generators/django_gen.py` — in `_scaffold`: +- after the `settings/production.py` write: `self._write("config/settings/test.py", t.settings_test())` +- after the users-app writes: `self._write("apps/users/migrations/__init__.py", "")` +- after the authentication-app writes: `self._write("apps/authentication/migrations/__init__.py", "")` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` → all pass. + +- [ ] **Step 5: Commit** + +```bash +git add -A src tests +git commit -m "fix: Django scaffold gets migrations packages and SQLite test settings + +Bare 'manage.py makemigrations' previously detected nothing (no +migrations package), and 'pytest' required a running PostgreSQL." +``` + +--- + +### Task 6: Alembic conditional auth import + Flask test secrets + +**Files:** +- Modify: `src/backendctl/templates/fastapi.py` (`alembic_env`), `src/backendctl/templates/flask.py` (`config_py`) +- Test: `tests/test_generators.py` + +- [ ] **Step 1: Write the failing tests** — append to `tests/test_generators.py`: + +```python +def test_alembic_env_skips_auth_import_when_no_auth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FASTAPI, auth=AuthType.NONE)).generate() + + assert "modules.auth" not in (root / "alembic/env.py").read_text() + + +def test_flask_test_secrets_are_long(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FLASK)).generate() + + config_py = (root / "src/demo_app/config.py").read_text() + assert 'JWT_SECRET_KEY: str = "test-secret"' not in config_py +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_generators.py -q` — both FAIL. + +- [ ] **Step 3: Implement.** + +`templates/fastapi.py` `alembic_env` — replace the unconditional import line with an interpolated one: + +```python +def alembic_env(c: ProjectConfig) -> str: + models_import = ( + f"\n# Import all models so Alembic can detect them\n" + f"from {c.slug}.modules.auth.models import User # noqa: F401\n" + if c.auth.value != "none" + else "" + ) +``` + +…and in the template body replace the two lines + +``` +# Import all models so Alembic can detect them +from {c.slug}.modules.auth.models import User # noqa: F401 +``` + +with `{models_import}`. + +`templates/flask.py` `config_py` — in `TestConfig`, replace both `"test-secret"` values with a 64-char literal: + +```python + JWT_SECRET_KEY: str = "test-secret-key-0123456789abcdef0123456789abcdef0123456789abcdef" + SECRET_KEY: str = "test-secret-key-0123456789abcdef0123456789abcdef0123456789abcdef" +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` → all pass. + +- [ ] **Step 5: Commit** + +```bash +git add -A src tests +git commit -m "fix: alembic env skips auth import when auth is disabled; longer Flask test secrets" +``` + +--- + +### Task 7: Framework-aware success panel, docs, version bump, e2e verification + +**Files:** +- Modify: `src/backendctl/core/config.py` (labels), `src/backendctl/core/console.py` (`print_done`), `src/backendctl/cli/new.py` (call site), `src/backendctl/templates/common.py` + `src/backendctl/templates/ai.py` (use shared labels), `README.md`, `pyproject.toml` + `src/backendctl/__init__.py` (version 0.3.0) +- Test: `tests/test_cli.py` + +**Interfaces:** +- Produces: `core.config.FRAMEWORK_LABELS: dict[Framework, str]`; `print_done(config: ProjectConfig, path: str) -> None`. + +- [ ] **Step 1: Write the failing test** — append to `tests/test_cli.py`: + +```python +def test_success_panel_is_framework_aware(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke( + app, ["new", "demo", "-f", "flask", "--yes", "--no-git", "--no-ai"] + ) + + assert result.exit_code == 0, result.output + assert "flask" in result.output + assert "cp .env.example" not in result.output + assert "fastapi dev" not in result.output +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_cli.py -q` — FAILS (panel still says `cp .env.example` / `fastapi dev`). + +- [ ] **Step 3: Implement.** + +`core/config.py` — add below the `Framework` enum: + +```python +FRAMEWORK_LABELS = { + Framework.FASTAPI: "FastAPI", + Framework.FLASK: "Flask", + Framework.DJANGO: "Django REST Framework", +} +``` + +`core/console.py` — replace `print_done` (add imports `from backendctl.core.config import FRAMEWORK_LABELS, Framework, PackageManager, ProjectConfig`): + +```python +def print_done(config: ProjectConfig, path: str) -> None: + run_cmd = { + Framework.FASTAPI: f"fastapi dev src/{config.slug}/main.py", + Framework.FLASK: f'flask --app "{config.slug}:create_app()" run --debug', + Framework.DJANGO: "python manage.py runserver", + }[config.framework] + if config.package_manager is PackageManager.UV: + run_cmd = f"uv run {run_cmd}" + else: + run_cmd = f"source .venv/bin/activate && {run_cmd}" + + lines = [ + f"[bold green]Project [cyan]{config.name}[/cyan] created![/bold green]", + "", + f" [dim]Framework :[/dim] {FRAMEWORK_LABELS[config.framework]}", + f" [dim]Location :[/dim] {path}", + "", + " [bold]Next steps:[/bold]", + f" [cyan]cd {config.name}[/cyan]", + " [cyan]docker compose up -d[/cyan] [dim]# start the database[/dim]", + " [dim]# .env was created with generated secrets — review it[/dim]", + f" [cyan]{run_cmd}[/cyan]", + ] + console.print(Panel("\n".join(lines), border_style="green", padding=(0, 2))) +``` + +`cli/new.py` — delete the local `framework_label` mapping at the end of `new_command` and call `print_done(config, str(project_path))`. + +`templates/common.py` and `templates/ai.py` — replace their local label dicts with `from backendctl.core.config import FRAMEWORK_LABELS` (`label = FRAMEWORK_LABELS[c.framework]`). + +`README.md` (tool repo) — in the flag table add: + +``` +| `--db-name` | string | Database name (default: project slug). | +| `--db-user` | string | PostgreSQL user (default: project slug). | +| `--db-password` | string | PostgreSQL password (default: auto-generated). | +``` + +In the wizard step list, after step 4 add: `5. Database name / user / password (defaults: slug / slug / auto-generated)` (renumber the rest). In "What you get", mention the generated `docker-compose.yml` and that `.env` ships with working credentials matching it. + +Version bump: `pyproject.toml` `version = "0.3.0"`; `src/backendctl/__init__.py` `__version__ = "0.3.0"`. + +- [ ] **Step 4: Run full suite + lint** + +Run: `uv run pytest -q` → all pass. `uv run ruff check src tests` → clean. + +- [ ] **Step 5: End-to-end verification** (scratchpad, real installs): + +```bash +cd /e2e-v2 +REPO="/Users/dipto/My_Works/Projects/Backend setup cli tool" +uv run --project "$REPO" backendctl new fastapiapp -f fastapi --db postgres --yes --no-ai --no-git +uv run --project "$REPO" backendctl new flaskapp -f flask --db postgres --yes --no-ai --no-git +uv run --project "$REPO" backendctl new djangoapp -f django --db postgres --yes --no-ai --no-git +uv run --project "$REPO" backendctl new mongoapp -f fastapi --db mongodb --yes --no-ai --no-git +uv run --project "$REPO" backendctl new noauthapp -f fastapi --auth none --yes --no-ai --no-git +for d in fastapiapp flaskapp djangoapp mongoapp; do (cd $d && uv run pytest -q); done +(cd noauthapp && uv run alembic revision --autogenerate -m init) # must NOT crash +(cd djangoapp && uv run python manage.py makemigrations) # must create 0001_initial +docker compose -f fastapiapp/docker-compose.yml config # valid YAML (if docker present) +``` + +Expected: every generated suite passes with no PostgreSQL running; makemigrations creates the users migration; alembic autogenerate succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: framework-aware success panel, docs, bump to 0.3.0" +``` + +--- + +## Final step: PR (GitHub flow) + +```bash +git push -u origin feat/dynamic-db-config +gh pr create --title "feat: dynamic database credentials + docker-compose generation" --body "..." +``` + +PR body summarizes the feature and the six review fixes; ends with the standard generation footer. diff --git a/docs/superpowers/specs/2026-07-14-dynamic-db-config-design.md b/docs/superpowers/specs/2026-07-14-dynamic-db-config-design.md new file mode 100644 index 0000000..ef11868 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-dynamic-db-config-design.md @@ -0,0 +1,87 @@ +# Dynamic database configuration + review fixes — Design + +**Date:** 2026-07-14 · **Status:** Approved · **Target version:** 0.3.0 + +## Goal + +Generated projects currently hardcode `DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/` — placeholder credentials that never match a real database, so a fresh project crashes on first boot. This design makes DB configuration dynamic (wizard prompts + flags), generates a matching `docker-compose.yml`, and fixes six bugs confirmed in the 2026-07-14 end-to-end review. + +## Non-goals + +- No live DB connection test during scaffolding (backendctl stays dependency-light). +- No Mongo-backed auth. Auth/users remain SQL-backed in all configurations. +- No Mongo authentication (root user/password) in generated compose or URIs — local dev Mongo runs unauthenticated; the db name is configurable. + +## 1. Config model (`core/config.py`) + +New dataclass, attached to `ProjectConfig` as `db_credentials`: + +```python +@dataclass +class DatabaseCredentials: + db_name: str = "" # resolves to slug + db_user: str = "" # resolves to slug (postgres only) + db_password: str = "" # resolves to secrets.token_urlsafe(16) (postgres only) + host: str = "localhost" + port: int = 5432 +``` + +Empty fields are resolved once, at generation time, by a `resolve_credentials(config)` step (or equivalent property logic) so templates always see final values. Mongo uses the same `db_name` and its own fixed defaults (`localhost:27017`). + +Validation: `db_name` and `db_user` must match `^[a-zA-Z_][a-zA-Z0-9_]*$` (safe for `POSTGRES_DB`/`POSTGRES_USER` and URL without quoting). Password is generated with `token_urlsafe`, which is URL-safe; user-supplied passwords containing characters that require URL-encoding are percent-encoded via `urllib.parse.quote` when building `DATABASE_URL`. + +## 2. Wizard + flags (`cli/new.py`) + +After the database selection step, when the choice includes PostgreSQL: + +1. `Database name:` (default: slug) +2. `Database user:` (default: slug) +3. `Database password:` — masked input (`questionary.password`); empty answer means "auto-generate". + +When the choice includes MongoDB: `MongoDB database name:` (default: slug). + +New flags, all usable with `--yes`: + +| Flag | Meaning | +|------|---------| +| `--db-name` | Database name (both engines) | +| `--db-user` | PostgreSQL user | +| `--db-password` | PostgreSQL password | + +Flags mark the field as provided → never re-asked. `--yes` without flags → defaults (slug/slug/random). Invalid `--db-name`/`--db-user` → exit 1 with the validation message. + +## 3. Rendered outputs + +- **`.env`** — real `DATABASE_URL` (and `MONGODB_URL`/`MONGODB_DB_NAME` or `MONGO_URI`) built from resolved credentials, plus existing generated secrets. +- **`.env.example`** — identical URL shape but password is the literal `change-me-db-password`. The real password never appears in a committed file. +- **`docker-compose.yml`** (new, `templates/common.py`, written whenever the project uses postgres or mongo): + - postgres: `postgres:16-alpine`, `POSTGRES_DB/USER/PASSWORD` from credentials, `127.0.0.1:5432:5432`, named volume, `pg_isready` healthcheck. + - mongo: `mongo:7`, `127.0.0.1:27017:27017`, named volume. +- **Generated README** — quickstart gains `docker compose up -d` before the migrations step. + +Implementation note: `.env` is currently produced by string-replacing placeholders in `env_example(c)`. Template functions gain an explicit `db_password` parameter (defaulting to the placeholder) instead; generators call once for `.env.example` and once with the real password for `.env`. `_write_if_absent` semantics for `.env` are unchanged. + +## 4. Bug fixes (from the 2026-07-14 review) + +| # | Bug | Fix | +|---|-----|-----| +| 1 | FastAPI mongo-only crashes (`No module named 'psycopg'`) | When `uses_sql` is false, `.env`/`.env.example` set `DATABASE_URL=sqlite:///./app.db`; no psycopg dep (unchanged); compose has only the mongo service. Generated README notes auth/users are stored in SQLite while Mongo serves app data via `get_mongo_db()`. | +| 2 | Django bare `makemigrations` detects nothing | Generator writes empty `apps/users/migrations/__init__.py` and `apps/authentication/migrations/__init__.py`. | +| 3 | Django `pytest` requires running Postgres | New `config/settings/test.py`: `DATABASES` from `TEST_DATABASE_URL` (default `sqlite:///test.db`); `pyproject.toml` sets `DJANGO_SETTINGS_MODULE = "config.settings.test"`. | +| 4 | `alembic revision --autogenerate` crashes with `--auth none` | The `from .modules.auth.models import User` line in `alembic/env.py` is emitted only when auth ≠ none. | +| 5 | First-run boot crash (no local Postgres) | Solved by docker-compose + README step (section 3). | +| 6 | Success box: always `cp .env.example .env` + `uv run fastapi dev` | `print_done` takes the config: framework-correct run command (fastapi dev / flask run / manage.py runserver, pip variants without `uv run`), and the `.env` line becomes "review .env — secrets were generated for you". Also: Flask `TestConfig` gets 64-char static test secrets (silences `InsecureKeyLengthWarning`). | + +## 5. Testing + +Extend the repo suite (`tests/`): + +- Compose file exists iff a DB is used; contains the right services and credentials per DB choice. +- `.env` contains resolved credentials; `.env.example` never contains the real password. +- Mongo-only FastAPI: pyproject has no psycopg, `.env` uses sqlite URL. +- Django: both `migrations/__init__.py` files exist; pyproject points pytest at `config.settings.test`. +- FastAPI `--auth none`: `alembic/env.py` has no auth import. +- CLI: `--db-name/--db-user/--db-password` seed config; invalid names rejected. +- Existing "every generated .py compiles" test keeps covering new/changed templates. + +Final verification: regenerate the e2e matrix (fastapi/flask/django × postgres, fastapi×mongo-only, fastapi×auth-none) in the scratchpad; run each generated test suite; boot FastAPI against `docker compose up -d db` if Docker is available, otherwise verify compose config statically (`docker compose config`). diff --git a/pyproject.toml b/pyproject.toml index 457e1c2..02a146c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "backendctl" -version = "0.2.0" +version = "0.3.0" description = "A CLI tool for scaffolding Python backend projects" readme = "README.md" requires-python = ">=3.11" diff --git a/src/backendctl/__init__.py b/src/backendctl/__init__.py index 0396e89..ae82184 100644 --- a/src/backendctl/__init__.py +++ b/src/backendctl/__init__.py @@ -1,3 +1,3 @@ """backendctl — stop rewriting boilerplate. start with a solid foundation.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/backendctl/cli/new.py b/src/backendctl/cli/new.py index 8a15e0e..d928a88 100644 --- a/src/backendctl/cli/new.py +++ b/src/backendctl/cli/new.py @@ -11,6 +11,7 @@ from backendctl.core.checks import run_preflight from backendctl.core.config import ( + DB_IDENT_RE, AIConfig, AIProvider, AuthType, @@ -68,6 +69,14 @@ def _validate_project_name(value: str) -> bool | str: return True +def _validate_db_identifier(value: str) -> bool | str: + if not value.strip(): + return "Value cannot be empty." + if not DB_IDENT_RE.match(value.strip()): + return "Use only letters, digits, and underscores; must not start with a digit." + return True + + def _parse_enum(enum_cls, raw: str, flag: str, allowed: str): try: return enum_cls(raw.lower()) @@ -163,6 +172,9 @@ def _run_wizard(config: ProjectConfig, provided: set[str], assume_yes: bool) -> ) ) + # 5b. Database credentials + _ask_db_credentials(config, provided, assume_yes) + # 6. Auth if "auth" not in provided and not assume_yes: config.auth = AuthType( @@ -240,6 +252,32 @@ def _run_wizard(config: ProjectConfig, provided: set[str], assume_yes: bool) -> ) +def _ask_db_credentials(config: ProjectConfig, provided: set[str], assume_yes: bool) -> None: + """Prompt for DB name/user/password. Unset fields resolve to defaults later.""" + if assume_yes: + return + if "db_name" not in provided: + config.db_credentials.db_name = _ask( + questionary.text, + message="Database name:", + default=config.slug, + validate=_validate_db_identifier, + ).strip() + if config.uses_sql: + if "db_user" not in provided: + config.db_credentials.db_user = _ask( + questionary.text, + message="Database user:", + default=config.slug, + validate=_validate_db_identifier, + ).strip() + if "db_password" not in provided: + config.db_credentials.db_password = _ask( + questionary.password, + message="Database password (leave empty to auto-generate):", + ) + + # ─── command ───────────────────────────────────────────────────────────────── @@ -271,6 +309,15 @@ def new_command( "--ai", help="AI assistant setup: none | claude | openai", ), + db_name: Optional[str] = typer.Option( + None, "--db-name", help="Database name (default: project slug)." + ), + db_user: Optional[str] = typer.Option( + None, "--db-user", help="PostgreSQL user (default: project slug)." + ), + db_password: Optional[str] = typer.Option( + None, "--db-password", help="PostgreSQL password (default: auto-generated)." + ), yes: bool = typer.Option( False, "--yes", @@ -325,6 +372,20 @@ def new_command( if auth: config.auth = _parse_enum(AuthType, auth, "auth", "jwt, none") provided.add("auth") + for flag, field_name, value in ( + ("--db-name", "db_name", db_name), + ("--db-user", "db_user", db_user), + ): + if value: + check = _validate_db_identifier(value) + if check is not True: + print_error(f"Invalid {flag} '{value}': {check}") + raise typer.Exit(1) + setattr(config.db_credentials, field_name, value.strip()) + provided.add(field_name) + if db_password: + config.db_credentials.db_password = db_password + provided.add("db_password") if no_ai: config.ai = AIConfig(provider=AIProvider.NONE) provided.add("ai") @@ -374,10 +435,4 @@ def new_command( print_error(f"Generation failed: {exc}") raise typer.Exit(1) - framework_label = { - Framework.FASTAPI: "FastAPI", - Framework.FLASK: "Flask", - Framework.DJANGO: "Django REST Framework", - }[config.framework] - - print_done(config.name, framework_label, str(project_path)) + print_done(config, str(project_path)) diff --git a/src/backendctl/core/checks.py b/src/backendctl/core/checks.py index d0ccbac..201ae6f 100644 --- a/src/backendctl/core/checks.py +++ b/src/backendctl/core/checks.py @@ -38,8 +38,11 @@ def check_uv() -> bool: " Docs: [link=https://docs.astral.sh/uv]https://docs.astral.sh/uv[/link]" ) return False - result = subprocess.run(["uv", "--version"], capture_output=True, text=True) - print_success(f"uv {result.stdout.strip()} detected.") + try: + result = subprocess.run(["uv", "--version"], capture_output=True, text=True) + print_success(f"uv {result.stdout.strip()} detected.") + except Exception: + print_warning("Found `uv` but could not determine version.") return True diff --git a/src/backendctl/core/config.py b/src/backendctl/core/config.py index 9bcb64d..1ce1d87 100644 --- a/src/backendctl/core/config.py +++ b/src/backendctl/core/config.py @@ -1,7 +1,10 @@ """ProjectConfig — single source of truth for what the user chose in the wizard.""" +import re +import secrets from dataclasses import dataclass, field from enum import Enum +from urllib.parse import quote class Framework(str, Enum): @@ -10,6 +13,13 @@ class Framework(str, Enum): DJANGO = "django" +FRAMEWORK_LABELS = { + Framework.FASTAPI: "FastAPI", + Framework.FLASK: "Flask", + Framework.DJANGO: "Django REST Framework", +} + + class PackageManager(str, Enum): UV = "uv" PIP = "pip" @@ -47,6 +57,38 @@ class AIConfig: install_sdk: bool = False # add SDK to dependencies +# Safe for POSTGRES_DB/POSTGRES_USER and for URLs without quoting. +DB_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*\Z") + + +@dataclass +class DatabaseCredentials: + """PostgreSQL credentials (Mongo shares db_name; runs unauthenticated locally).""" + + db_name: str = "" # resolves to project slug + db_user: str = "" # resolves to project slug + db_password: str = "" # resolves to a random token + host: str = "localhost" + port: int = 5432 + + def resolve(self, slug: str) -> None: + """Fill unset fields with defaults; validate identifiers.""" + self.db_name = (self.db_name or slug).strip() + self.db_user = (self.db_user or slug).strip() + for label, value in (("database name", self.db_name), ("database user", self.db_user)): + if not DB_IDENT_RE.match(value): + raise ValueError( + f"Invalid {label} {value!r}: use only letters, digits, and " + "underscores; must not start with a digit." + ) + if not self.db_password: + self.db_password = secrets.token_urlsafe(16) + + def url(self, scheme: str, password: str | None = None) -> str: + pw = quote(self.db_password if password is None else password, safe="") + return f"{scheme}://{self.db_user}:{pw}@{self.host}:{self.port}/{self.db_name}" + + @dataclass class ProjectConfig: name: str = "" @@ -56,6 +98,7 @@ class ProjectConfig: auth: AuthType = AuthType.JWT user_model: UserModelConfig = field(default_factory=UserModelConfig) ai: AIConfig = field(default_factory=AIConfig) + db_credentials: DatabaseCredentials = field(default_factory=DatabaseCredentials) init_git: bool = True force: bool = False # allow scaffolding into a non-empty directory diff --git a/src/backendctl/core/console.py b/src/backendctl/core/console.py index 3e45c53..e107077 100644 --- a/src/backendctl/core/console.py +++ b/src/backendctl/core/console.py @@ -4,6 +4,8 @@ from rich.panel import Panel from rich.text import Text +from backendctl.core.config import FRAMEWORK_LABELS, Framework, PackageManager, ProjectConfig + console = Console() @@ -34,17 +36,27 @@ def print_step(step: str) -> None: console.print(f"\n[bold cyan] {step}[/bold cyan]") -def print_done(project_name: str, framework: str, path: str) -> None: +def print_done(config: ProjectConfig, path: str) -> None: + run_cmd = { + Framework.FASTAPI: f"fastapi dev src/{config.slug}/main.py", + Framework.FLASK: f'flask --app "{config.slug}:create_app()" run --debug', + Framework.DJANGO: "python manage.py runserver", + }[config.framework] + if config.package_manager is PackageManager.UV: + run_cmd = f"uv run {run_cmd}" + else: + run_cmd = f"source .venv/bin/activate && {run_cmd}" + lines = [ - f"[bold green]Project [cyan]{project_name}[/cyan] created![/bold green]", + f"[bold green]Project [cyan]{config.name}[/cyan] created![/bold green]", "", - f" [dim]Framework :[/dim] {framework}", + f" [dim]Framework :[/dim] {FRAMEWORK_LABELS[config.framework]}", f" [dim]Location :[/dim] {path}", "", " [bold]Next steps:[/bold]", - f" [cyan]cd {project_name}[/cyan]", - " [cyan]cp .env.example .env[/cyan] [dim]# fill in your secrets[/dim]", - " [cyan]uv run fastapi dev[/cyan] " - "[dim]# or flask run / python manage.py runserver[/dim]", + f" [cyan]cd {config.name}[/cyan]", + " [cyan]docker compose up -d[/cyan] [dim]# start the database[/dim]", + " [dim]# .env was created with generated secrets — review it[/dim]", + f" [cyan]{run_cmd}[/cyan]", ] console.print(Panel("\n".join(lines), border_style="green", padding=(0, 2))) diff --git a/src/backendctl/generators/base.py b/src/backendctl/generators/base.py index d95fdd4..1dbefff 100644 --- a/src/backendctl/generators/base.py +++ b/src/backendctl/generators/base.py @@ -38,6 +38,11 @@ def __init__(self, config: ProjectConfig) -> None: if self.root.resolve().parent != Path.cwd().resolve(): raise ScaffoldError(f"Refusing to scaffold outside the current directory: {self.root}") + try: + config.db_credentials.resolve(config.slug) + except ValueError as exc: + raise ScaffoldError(str(exc)) from exc + # ─── public ─────────────────────────────────────────────────────────── def generate(self) -> Path: @@ -95,6 +100,7 @@ def _touch(self, rel_path: str) -> None: def _write_common_files(self) -> None: from backendctl.templates.common import ( + docker_compose, dockerfile, editorconfig, gitignore, @@ -107,6 +113,7 @@ def _write_common_files(self) -> None: self._write(".pre-commit-config.yaml", pre_commit_config()) self._write("README.md", readme(self.config)) self._write("Dockerfile", dockerfile(self.config)) + self._write("docker-compose.yml", docker_compose(self.config)) print_info("Common files written.") def _write_ai_files(self) -> None: diff --git a/src/backendctl/generators/django_gen.py b/src/backendctl/generators/django_gen.py index 46e23fd..4664437 100644 --- a/src/backendctl/generators/django_gen.py +++ b/src/backendctl/generators/django_gen.py @@ -16,7 +16,9 @@ def _scaffold(self) -> None: self._write(".env.example", t.env_example(c)) self._write_if_absent( ".env", - t.env_example(c).replace("change-me-to-a-long-random-string", secrets.token_hex(32)), + t.env_example(c, db_password=c.db_credentials.db_password).replace( + "change-me-to-a-long-random-string", secrets.token_hex(32) + ), ) # manage.py @@ -28,6 +30,7 @@ def _scaffold(self) -> None: self._write("config/settings/base.py", t.settings_base(c)) self._write("config/settings/development.py", t.settings_development()) self._write("config/settings/production.py", t.settings_production()) + self._write("config/settings/test.py", t.settings_test()) self._write("config/urls.py", t.config_urls(c)) self._write("config/wsgi.py", t.config_wsgi(c)) self._write("config/asgi.py", t.config_asgi(c)) @@ -42,6 +45,7 @@ def _scaffold(self) -> None: self._write("apps/users/serializers.py", t.users_serializers(c)) self._write("apps/users/views.py", t.users_views()) self._write("apps/users/urls.py", t.users_urls()) + self._write("apps/users/migrations/__init__.py", "") # apps/authentication/ self._write("apps/authentication/__init__.py", "") @@ -49,6 +53,7 @@ def _scaffold(self) -> None: self._write("apps/authentication/serializers.py", t.auth_serializers(c)) self._write("apps/authentication/views.py", t.auth_views(c)) self._write("apps/authentication/urls.py", t.auth_urls()) + self._write("apps/authentication/migrations/__init__.py", "") # core/ self._write("core/__init__.py", "") diff --git a/src/backendctl/generators/fastapi_gen.py b/src/backendctl/generators/fastapi_gen.py index be1f7aa..758ec93 100644 --- a/src/backendctl/generators/fastapi_gen.py +++ b/src/backendctl/generators/fastapi_gen.py @@ -16,7 +16,9 @@ def _scaffold(self) -> None: self._write(".env.example", t.env_example(c)) self._write_if_absent( ".env", - t.env_example(c).replace("change-me-to-a-long-random-string", _random_key()), + t.env_example(c, db_password=c.db_credentials.db_password).replace( + "change-me-to-a-long-random-string", _random_key() + ), ) # Package __init__ diff --git a/src/backendctl/generators/flask_gen.py b/src/backendctl/generators/flask_gen.py index 9c8162d..4780a49 100644 --- a/src/backendctl/generators/flask_gen.py +++ b/src/backendctl/generators/flask_gen.py @@ -17,7 +17,7 @@ def _scaffold(self) -> None: self._write(".env.example", t.env_example(c)) self._write_if_absent( ".env", - t.env_example(c) + t.env_example(c, db_password=c.db_credentials.db_password) .replace("change-me-to-a-long-random-string", secrets.token_hex(32)) .replace("another-long-random-secret", secrets.token_hex(32)), ) diff --git a/src/backendctl/main.py b/src/backendctl/main.py index 985dbf0..0aaae1b 100644 --- a/src/backendctl/main.py +++ b/src/backendctl/main.py @@ -1,5 +1,7 @@ """Entry point for the backendctl CLI.""" +from __future__ import annotations + import typer from backendctl import __version__ @@ -16,7 +18,7 @@ app.command("new", help="Create a new backend project.")(new_command) -def version_callback(value: bool) -> None: +def version_callback(value: bool | None) -> None: if value: typer.echo(f"backendctl v{__version__}") raise typer.Exit() @@ -24,7 +26,7 @@ def version_callback(value: bool) -> None: @app.callback() def main( - version: bool = typer.Option( + version: bool | None = typer.Option( None, "--version", "-v", diff --git a/src/backendctl/templates/ai.py b/src/backendctl/templates/ai.py index 7d5b3db..a74cc81 100644 --- a/src/backendctl/templates/ai.py +++ b/src/backendctl/templates/ai.py @@ -2,15 +2,11 @@ from __future__ import annotations -from backendctl.core.config import Framework, ProjectConfig +from backendctl.core.config import FRAMEWORK_LABELS, ProjectConfig def claude_md(config: ProjectConfig) -> str: - framework_label = { - Framework.FASTAPI: "FastAPI", - Framework.FLASK: "Flask", - Framework.DJANGO: "Django REST Framework", - }[config.framework] + framework_label = FRAMEWORK_LABELS[config.framework] provider_note = { "claude": "This project uses Claude as the AI coding assistant.", @@ -83,11 +79,7 @@ def claude_md(config: ProjectConfig) -> str: def cursorrules(config: ProjectConfig) -> str: - framework_label = { - Framework.FASTAPI: "FastAPI", - Framework.FLASK: "Flask", - Framework.DJANGO: "Django REST Framework", - }[config.framework] + framework_label = FRAMEWORK_LABELS[config.framework] return f"""\ # {config.name} — Cursor Rules diff --git a/src/backendctl/templates/common.py b/src/backendctl/templates/common.py index f9ad9e6..38b6eb5 100644 --- a/src/backendctl/templates/common.py +++ b/src/backendctl/templates/common.py @@ -2,7 +2,11 @@ from __future__ import annotations -from backendctl.core.config import Framework, ProjectConfig +import json + +from backendctl.core.config import FRAMEWORK_LABELS, Framework, ProjectConfig + +DB_PASSWORD_PLACEHOLDER = "change-me-db-password" def gitignore() -> str: @@ -121,11 +125,7 @@ def pre_commit_config() -> str: def readme(c: ProjectConfig) -> str: - label = { - Framework.FASTAPI: "FastAPI", - Framework.FLASK: "Flask", - Framework.DJANGO: "Django REST Framework", - }[c.framework] + label = FRAMEWORK_LABELS[c.framework] run_dev = { Framework.FASTAPI: f"uv run fastapi dev src/{c.slug}/main.py", @@ -151,6 +151,13 @@ def readme(c: ProjectConfig) -> str: """ ) + mongo_note = ( + "\n> **Note:** auth/user data is stored in SQLite (`app.db`); MongoDB is wired " + "for application data.\n" + if c.uses_mongo and not c.uses_sql + else "" + ) + return f"""\ # {c.name} @@ -166,13 +173,16 @@ def readme(c: ProjectConfig) -> str: # A .env with freshly generated secrets was created for you. # Review it and point DATABASE_URL at your database. -# 3. Apply database migrations +# 3. Start the database (Docker) +docker compose up -d + +# 4. Apply database migrations {migrate} -# 4. Run the dev server +# 5. Run the dev server {run_dev} ``` - +{mongo_note} ## Testing ```bash @@ -217,3 +227,46 @@ def dockerfile(c: ProjectConfig) -> str: {cmd} """ + + +def docker_compose(c: ProjectConfig) -> str: + creds = c.db_credentials + services: list[str] = [] + volumes: list[str] = [] + + if c.uses_sql: + services.append( + f"""\ + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: {json.dumps(creds.db_name)} + POSTGRES_USER: {json.dumps(creds.db_user)} + POSTGRES_PASSWORD: {json.dumps(creds.db_password)} + ports: + - "127.0.0.1:5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {creds.db_user} -d {creds.db_name}"] + interval: 5s + timeout: 3s + retries: 10 +""" + ) + volumes.append(" postgres_data:") + + if c.uses_mongo: + services.append( + """\ + mongo: + image: mongo:7 + ports: + - "127.0.0.1:27017:27017" + volumes: + - mongo_data:/data/db +""" + ) + volumes.append(" mongo_data:") + + return "services:\n" + "\n".join(services) + "\nvolumes:\n" + "\n".join(volumes) + "\n" diff --git a/src/backendctl/templates/django.py b/src/backendctl/templates/django.py index 681cb27..5edfb99 100644 --- a/src/backendctl/templates/django.py +++ b/src/backendctl/templates/django.py @@ -54,8 +54,11 @@ def pyproject_toml(c: ProjectConfig) -> str: packages = ["config", "apps", "core"] [tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "config.settings.development" +DJANGO_SETTINGS_MODULE = "config.settings.test" testpaths = ["tests"] +# Migrations aren't generated until `manage.py makemigrations` is run; skip +# applying them for tests so the suite passes on a fresh scaffold. +addopts = "--no-migrations" [tool.ruff] line-length = 100 @@ -75,15 +78,17 @@ def _ai_dep(c: ProjectConfig) -> str: }.get(c.ai.provider.value, "") -def env_example(c: ProjectConfig) -> str: - pg = f"postgres://user:password@localhost:5432/{c.slug}" if c.uses_sql else "" +def env_example(c: ProjectConfig, db_password: str | None = None) -> str: + from backendctl.templates.common import DB_PASSWORD_PLACEHOLDER + + db_url = c.db_credentials.url("postgres", password=db_password or DB_PASSWORD_PLACEHOLDER) return f"""\ DJANGO_SECRET_KEY=change-me-to-a-long-random-string DJANGO_DEBUG=True DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 # Database -DATABASE_URL={pg} +DATABASE_URL={db_url} TEST_DATABASE_URL=sqlite:///test.db # CORS @@ -246,6 +251,18 @@ def settings_production() -> str: """ +def settings_test() -> str: + return """\ +from config.settings.base import * # noqa: F401, F403 +from config.settings.base import env + +DEBUG = True + +# Tests run on SQLite by default — no PostgreSQL server required. +DATABASES = {"default": env.db("TEST_DATABASE_URL", default="sqlite:///test.db")} +""" + + def config_urls(c: ProjectConfig) -> str: return f"""\ from django.urls import include, path diff --git a/src/backendctl/templates/fastapi.py b/src/backendctl/templates/fastapi.py index 9b2d7f9..cfdf1ec 100644 --- a/src/backendctl/templates/fastapi.py +++ b/src/backendctl/templates/fastapi.py @@ -86,10 +86,20 @@ def _ai_dep(c: ProjectConfig) -> str: # ─── .env.example ──────────────────────────────────────────────────────────── -def env_example(c: ProjectConfig) -> str: +def env_example(c: ProjectConfig, db_password: str | None = None) -> str: + from backendctl.templates.common import DB_PASSWORD_PLACEHOLDER + + creds = c.db_credentials mongo_line = ( - "\nMONGODB_URL=mongodb://localhost:27017\nMONGODB_DB_NAME=mydb\n" if c.uses_mongo else "" + f"\nMONGODB_URL=mongodb://localhost:27017\nMONGODB_DB_NAME={creds.db_name}\n" + if c.uses_mongo + else "" ) + if c.uses_sql: + db_url = creds.url("postgresql+psycopg", password=db_password or DB_PASSWORD_PLACEHOLDER) + else: + # No PostgreSQL selected: auth/user data lives in SQLite (no extra driver). + db_url = "sqlite:///./app.db" return f"""\ # ── Security ────────────────────────────────────────────── @@ -101,7 +111,7 @@ def env_example(c: ProjectConfig) -> str: # ── Database ────────────────────────────────────────────── # Production (PostgreSQL) -DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/{c.slug} +DATABASE_URL={db_url} # Test (SQLite — used automatically in tests) TEST_DATABASE_URL=sqlite:///./test.db @@ -652,16 +662,19 @@ async def get_me(current_user: User = Depends(get_current_user)): def alembic_env(c: ProjectConfig) -> str: + models_import = ( + f"\n# Import all models so Alembic can detect them\n" + f"from {c.slug}.modules.auth.models import User # noqa: F401\n" + if c.auth.value != "none" + else "" + ) return f"""\ import os from logging.config import fileConfig from alembic import context from sqlmodel import SQLModel - -# Import all models so Alembic can detect them -from {c.slug}.modules.auth.models import User # noqa: F401 - +{models_import} config = context.config fileConfig(config.config_file_name) diff --git a/src/backendctl/templates/flask.py b/src/backendctl/templates/flask.py index 9208af8..3a30c4f 100644 --- a/src/backendctl/templates/flask.py +++ b/src/backendctl/templates/flask.py @@ -71,15 +71,26 @@ def _ai_dep(c: ProjectConfig) -> str: }.get(c.ai.provider.value, "") -def env_example(c: ProjectConfig) -> str: - mongo_block = "\n# MongoDB\nMONGO_URI=mongodb://localhost:27017/mydb\n" if c.uses_mongo else "" +def env_example(c: ProjectConfig, db_password: str | None = None) -> str: + from backendctl.templates.common import DB_PASSWORD_PLACEHOLDER + + creds = c.db_credentials + mongo_block = ( + f"\n# MongoDB\nMONGO_URI=mongodb://localhost:27017/{creds.db_name}\n" + if c.uses_mongo + else "" + ) + if c.uses_sql: + db_url = creds.url("postgresql+psycopg", password=db_password or DB_PASSWORD_PLACEHOLDER) + else: + db_url = "sqlite:///app.db" return f"""\ FLASK_ENV=development SECRET_KEY=change-me-to-a-long-random-string DEBUG=true # Database (PostgreSQL) -DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/{c.slug} +DATABASE_URL={db_url} TEST_DATABASE_URL=sqlite:///test.db {mongo_block} # JWT @@ -181,8 +192,8 @@ class Config: class TestConfig(Config): TESTING: bool = True SQLALCHEMY_DATABASE_URI: str = os.getenv("TEST_DATABASE_URL", "sqlite:///test.db") - JWT_SECRET_KEY: str = "test-secret" - SECRET_KEY: str = "test-secret" + JWT_SECRET_KEY: str = "test-secret-key-0123456789abcdef0123456789abcdef0123456789abcdef" + SECRET_KEY: str = "test-secret-key-0123456789abcdef0123456789abcdef0123456789abcdef" RATELIMIT_ENABLED: bool = False """ diff --git a/tests/test_cli.py b/tests/test_cli.py index 83826c7..f49b9b9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,3 +57,76 @@ def test_nonempty_directory_without_force_fails_noninteractively(tmp_path, monke assert result.exit_code == 1 assert (target / "precious.txt").read_text() == "keep" + + +def _stub_generation(monkeypatch): + from backendctl.generators import base + + monkeypatch.setattr(base.BaseGenerator, "_install_deps", lambda self: None) + monkeypatch.setattr(base.BaseGenerator, "_git_init", lambda self: None) + monkeypatch.setattr("backendctl.cli.new.run_preflight", lambda *a, **k: True) + + +def test_db_flags_flow_into_generated_env(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke( + app, + [ + "new", + "demo", + "--framework", + "fastapi", + "--db", + "postgres", + "--db-name", + "customdb", + "--db-user", + "alice", + "--db-password", + "pw12345", + "--yes", + "--no-git", + "--no-ai", + ], + ) + + assert result.exit_code == 0, result.output + env = (tmp_path / "demo" / ".env").read_text() + assert "postgresql+psycopg://alice:pw12345@localhost:5432/customdb" in env + + +@pytest.mark.parametrize("flag", ["--db-name", "--db-user"]) +def test_invalid_db_identifier_flags_rejected(flag: str, tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke(app, ["new", "demo", flag, "bad;name", "--yes", "--no-git", "--no-ai"]) + + assert result.exit_code == 1 + assert not (tmp_path / "demo").exists() + + +def test_yes_generates_random_password(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke(app, ["new", "demo", "--yes", "--no-git", "--no-ai"]) + + assert result.exit_code == 0, result.output + env = (tmp_path / "demo" / ".env").read_text() + assert "change-me-db-password" not in env + assert "user:password@" not in env + + +def test_success_panel_is_framework_aware(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + result = runner.invoke(app, ["new", "demo", "-f", "flask", "--yes", "--no-git", "--no-ai"]) + + assert result.exit_code == 0, result.output + assert "flask" in result.output + assert "cp .env.example" not in result.output + assert "fastapi dev" not in result.output diff --git a/tests/test_config.py b/tests/test_config.py index 1fcafa2..171e39f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ from backendctl.core.config import ( Database, + DatabaseCredentials, ProjectConfig, ) @@ -35,3 +36,40 @@ def test_database_capability_flags(database: Database, uses_sql: bool, uses_mong config = ProjectConfig(database=database) assert config.uses_sql is uses_sql assert config.uses_mongo is uses_mongo + + +def test_credentials_resolve_defaults_to_slug() -> None: + creds = DatabaseCredentials() + creds.resolve("my_app") + assert creds.db_name == "my_app" + assert creds.db_user == "my_app" + assert len(creds.db_password) >= 16 # auto-generated + + +def test_credentials_resolve_keeps_explicit_values() -> None: + creds = DatabaseCredentials(db_name="mydb", db_user="alice", db_password="pw") + creds.resolve("my_app") + assert (creds.db_name, creds.db_user, creds.db_password) == ("mydb", "alice", "pw") + + +@pytest.mark.parametrize("bad", ["1db", "my-db", "my db", "db;drop"]) +def test_credentials_resolve_rejects_invalid_identifiers(bad: str) -> None: + creds = DatabaseCredentials(db_name=bad) + with pytest.raises(ValueError): + creds.resolve("my_app") + + +def test_credentials_url_builds_and_percent_encodes() -> None: + creds = DatabaseCredentials(db_name="mydb", db_user="alice", db_password="p@ss:word") + creds.resolve("x") + assert creds.url("postgresql+psycopg") == ( + "postgresql+psycopg://alice:p%40ss%3Aword@localhost:5432/mydb" + ) + # explicit password override (used for .env.example) + assert creds.url("postgres", password="change-me-db-password").endswith( + "://alice:change-me-db-password@localhost:5432/mydb" + ) + + +def test_project_config_has_credentials() -> None: + assert isinstance(ProjectConfig().db_credentials, DatabaseCredentials) diff --git a/tests/test_generators.py b/tests/test_generators.py index 5d930c9..a3ae765 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -198,3 +198,172 @@ def test_failed_generation_keeps_preexisting_directory( generator.generate() assert (existing / "precious.txt").read_text() == "keep me" + + +# ─── DB credentials wired into env templates ──────────────────────────────── + + +def test_env_gets_real_credentials_example_gets_placeholder( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI) + config.db_credentials.db_name = "mydb" + config.db_credentials.db_user = "alice" + config.db_credentials.db_password = "s3cretpw" + + root = get_generator(config).generate() + + env = (root / ".env").read_text() + example = (root / ".env.example").read_text() + assert "postgresql+psycopg://alice:s3cretpw@localhost:5432/mydb" in env + assert "s3cretpw" not in example + assert "alice:change-me-db-password@localhost:5432/mydb" in example + + +def test_credentials_default_to_slug(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FLASK) + + root = get_generator(config).generate() + + assert "://demo_app:" in (root / ".env").read_text() + assert "/demo_app" in (root / ".env").read_text() + + +def test_django_env_uses_postgres_scheme(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.DJANGO) + config.db_credentials.db_password = "djpw" + + root = get_generator(config).generate() + + assert ( + "DATABASE_URL=postgres://demo_app:djpw@localhost:5432/demo_app" + in (root / ".env").read_text() + ) + + +def test_mongo_only_falls_back_to_sqlite(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + for framework in (Framework.FASTAPI, Framework.FLASK): + config = _make_config(framework, database=Database.MONGODB) + config.name = f"demo_{framework.value}" + root = get_generator(config).generate() + env = (root / ".env").read_text() + assert "sqlite:///" in env, framework + assert "postgresql+psycopg" not in env, framework + assert "psycopg" not in (root / "pyproject.toml").read_text(), framework + + +def test_mongo_db_name_flows_into_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI, database=Database.MONGODB) + config.db_credentials.db_name = "appdata" + + root = get_generator(config).generate() + + assert "MONGODB_DB_NAME=appdata" in (root / ".env").read_text() + + +def test_invalid_credentials_raise_scaffold_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI) + config.db_credentials.db_user = "bad;user" + + with pytest.raises(base.ScaffoldError): + get_generator(config) + + +def test_compose_postgres_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI) + config.db_credentials.db_password = "pgpw" + + root = get_generator(config).generate() + + compose = (root / "docker-compose.yml").read_text() + assert "postgres:16-alpine" in compose + assert 'POSTGRES_DB: "demo_app"' in compose + assert 'POSTGRES_PASSWORD: "pgpw"' in compose + assert "mongo" not in compose + + +def test_compose_mongo_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FASTAPI, database=Database.MONGODB) + + root = get_generator(config).generate() + + compose = (root / "docker-compose.yml").read_text() + assert "mongo:7" in compose + assert "postgres" not in compose + + +def test_compose_both_and_readme_mentions_compose( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FLASK, database=Database.BOTH) + + root = get_generator(config).generate() + + compose = (root / "docker-compose.yml").read_text() + assert "postgres:16-alpine" in compose and "mongo:7" in compose + assert "docker compose up -d" in (root / "README.md").read_text() + + +# ─── Django-specific fixes ──────────────────────────────────────────────────── + + +def test_django_migrations_packages_exist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.DJANGO)).generate() + + assert (root / "apps/users/migrations/__init__.py").is_file() + assert (root / "apps/authentication/migrations/__init__.py").is_file() + + +def test_django_pytest_uses_sqlite_test_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.DJANGO)).generate() + + assert ( + 'DJANGO_SETTINGS_MODULE = "config.settings.test"' in (root / "pyproject.toml").read_text() + ) + test_settings = (root / "config/settings/test.py").read_text() + assert "TEST_DATABASE_URL" in test_settings + assert "sqlite" in test_settings + + +# ─── Bug fixes (alembic & Flask test secrets) ──────────────────────────────── + + +def test_alembic_env_skips_auth_import_when_no_auth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FASTAPI, auth=AuthType.NONE)).generate() + + assert "modules.auth" not in (root / "alembic/env.py").read_text() + + +def test_alembic_env_keeps_auth_import_with_auth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FASTAPI)).generate() + + assert "modules.auth.models import User" in (root / "alembic/env.py").read_text() + + +def test_flask_test_secrets_are_long(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FLASK)).generate() + + config_py = (root / "src/demo_app/config.py").read_text() + assert 'JWT_SECRET_KEY: str = "test-secret"' not in config_py diff --git a/uv.lock b/uv.lock index f629e2f..e4a563a 100644 --- a/uv.lock +++ b/uv.lock @@ -57,7 +57,7 @@ wheels = [ [[package]] name = "backendctl" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "questionary" },