docs: add rename and CLI implementation plan

This commit is contained in:
Johan Lundberg 2026-02-16 15:26:25 +01:00
parent 5d97e496f1
commit 32b75cf92d
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1

View file

@ -0,0 +1,645 @@
# Rename to Porchlight + CLI Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rename the project from `fastapi-oidc-op` / `fastapi_oidc_op` to `porchlight` and add a typer-based CLI with `init-admin` and `create-invite` commands.
**Architecture:** The rename is a mechanical find-and-replace across the entire codebase (package dir, imports, pyproject.toml, Dockerfile). The CLI adds `typer>=0.15` as a dependency, a shared `open_db()` context manager extracted from the lifespan DB setup, and a `cli.py` module with two async command implementations bridged to sync via `asyncio.run()`.
**Tech Stack:** Python 3.13, typer, aiosqlite, pydantic-settings
**Quality gate:** `./scripts/check.sh` (ruff format, ruff check, ty check, pytest — 147 tests)
---
### Task 1: Rename package directory
**Files:**
- Rename: `src/fastapi_oidc_op/``src/porchlight/`
**Step 1: Move the directory**
```bash
git mv src/fastapi_oidc_op src/porchlight
```
**Step 2: Verify directory exists**
```bash
ls src/porchlight/app.py
```
Expected: file exists.
**Step 3: Commit**
```bash
git add -A && git commit -m "refactor: rename package directory fastapi_oidc_op to porchlight"
```
---
### Task 2: Update pyproject.toml
**Files:**
- Modify: `pyproject.toml` (lines 2, 24, 31)
**Step 1: Update project name, script entry point, and hatch packages**
Change these three lines:
```
line 2: name = "fastapi-oidc-op" → name = "porchlight"
line 24: fastapi-oidc-op = "fastapi_oidc_op.cli:main" → porchlight = "porchlight.cli:main"
line 31: packages = ["src/fastapi_oidc_op"] → packages = ["src/porchlight"]
```
**Step 2: Add typer dependency**
Add `"typer>=0.15"` to the `dependencies` list.
**Step 3: Regenerate lock file**
```bash
uv lock
```
**Step 4: Commit**
```bash
git add pyproject.toml uv.lock && git commit -m "refactor: update pyproject.toml for porchlight rename and add typer dependency"
```
---
### Task 3: Update all imports in source files
**Files:**
- Modify all `.py` files under `src/porchlight/` that contain `fastapi_oidc_op`
The following files need `fastapi_oidc_op` replaced with `porchlight` in all import statements:
- `src/porchlight/app.py` (lines 13-22: 10 imports)
- `src/porchlight/config.py` (line 1: comment only)
- `src/porchlight/dependencies.py` (line 3)
- `src/porchlight/authn/routes.py` (lines 10-11)
- `src/porchlight/invite/service.py` (lines 4-5)
- `src/porchlight/manage/routes.py` (lines 7-8)
- `src/porchlight/oidc/claims.py` (line 5)
- `src/porchlight/oidc/endpoints.py` (line 13)
- `src/porchlight/oidc/provider.py` (lines 7-8)
- `src/porchlight/store/protocols.py` (line 3)
- `src/porchlight/store/sqlite/repositories.py` (lines 5-6)
- `src/porchlight/userid.py` (line 5)
**Step 1: Replace all occurrences**
In every `.py` file under `src/porchlight/`, replace `fastapi_oidc_op` with `porchlight`.
**Step 2: Verify no stale references remain**
```bash
grep -r "fastapi_oidc_op" src/
```
Expected: no output.
**Step 3: Commit**
```bash
git add src/ && git commit -m "refactor: update all source imports from fastapi_oidc_op to porchlight"
```
---
### Task 4: Update all imports in test files
**Files:**
- Modify all `.py` files under `tests/` that contain `fastapi_oidc_op`
Files with import references to update (`fastapi_oidc_op``porchlight`):
- `tests/conftest.py` (lines 6-7)
- `tests/test_app.py` (lines 19, 34)
- `tests/test_config.py` (line 4)
- `tests/test_models.py` (line 3)
- `tests/test_userid.py` (lines 5-6)
- `tests/e2e/setup_db.py` (lines 17-20)
- `tests/test_auth_routes/test_last_credential_guard.py` (lines 7-8)
- `tests/test_auth_routes/test_manage_credentials_page.py` (lines 6-7)
- `tests/test_auth_routes/test_manage_password_credential.py` (lines 6-7)
- `tests/test_auth_routes/test_manage_webauthn_credential.py` (lines 20-21)
- `tests/test_auth_routes/test_password_login.py` (lines 6-7)
- `tests/test_auth_routes/test_register_magic_link.py` (line 5)
- `tests/test_auth_routes/test_session_deps.py` (line 6)
- `tests/test_auth_routes/test_webauthn_login.py` (line 12)
- `tests/test_authn/test_password.py` (line 3)
- `tests/test_authn/test_webauthn.py` (line 21)
- `tests/test_invite/test_service.py` (lines 7-9, 12 — note line 12 has a Path string)
- `tests/test_oidc/test_claims.py` (lines 3-4)
- `tests/test_oidc/test_e2e_flow.py` (lines 12-13)
- `tests/test_oidc/test_login_oidc_redirect.py` (lines 7-8)
- `tests/test_oidc/test_provider.py` (lines 4-5, 52)
- `tests/test_oidc/test_token.py` (lines 9-10)
- `tests/test_oidc/test_userinfo.py` (lines 9-10)
- `tests/test_store/conftest.py` (lines 6, 9 — note line 9 has a Path string)
- `tests/test_store/test_exceptions.py` (line 1)
- `tests/test_store/test_migrations.py` (lines 5, 8 — note line 8 has a Path string)
- `tests/test_store/test_protocols.py` (line 3)
- `tests/test_store/test_sqlite_credential_repo.py` (lines 4-7)
- `tests/test_store/test_sqlite_magic_link_repo.py` (lines 6-9)
- `tests/test_store/test_sqlite_user_repo.py` (lines 4-7)
**Important:** Three files have `Path(...)` strings containing `"fastapi_oidc_op"` that also need updating:
- `tests/test_invite/test_service.py` line 12: `"src" / "fastapi_oidc_op" / "store"``"src" / "porchlight" / "store"`
- `tests/test_store/conftest.py` line 9: same pattern
- `tests/test_store/test_migrations.py` line 8: same pattern
**Step 1: Replace all occurrences**
In every `.py` file under `tests/`, replace `fastapi_oidc_op` with `porchlight` (both in imports and string literals).
**Step 2: Verify no stale references remain**
```bash
grep -r "fastapi_oidc_op" tests/
```
Expected: no output.
**Step 3: Commit**
```bash
git add tests/ && git commit -m "refactor: update all test imports from fastapi_oidc_op to porchlight"
```
---
### Task 5: Update Dockerfile
**Files:**
- Modify: `Dockerfile` (lines 29, 45)
**Step 1: Replace module references**
```
line 29: "fastapi_oidc_op.app:create_app" → "porchlight.app:create_app"
line 45: "fastapi_oidc_op.app:create_app" → "porchlight.app:create_app"
```
**Step 2: Commit**
```bash
git add Dockerfile && git commit -m "refactor: update Dockerfile for porchlight rename"
```
---
### Task 6: Run quality gate to verify rename
**Step 1: Run full quality gate**
```bash
./scripts/check.sh
```
Expected: all 147 tests pass, ruff clean, ty clean.
**Step 2: Verify no remaining references**
```bash
grep -r "fastapi_oidc_op" src/ tests/ Dockerfile pyproject.toml docker-compose.yml
```
Expected: no output (docker-compose.yml has no package refs, only env vars with `OIDC_OP_` prefix which we keep).
**Step 3: If anything fails, fix and amend the relevant commit**
---
### Task 7: Extract `open_db()` context manager
**Files:**
- Create: `src/porchlight/store/sqlite/db.py`
- Test: `tests/test_store/test_db.py`
- Modify: `src/porchlight/app.py` (refactor lifespan to use `open_db`)
**Step 1: Write the failing test**
```python
# tests/test_store/test_db.py
from pathlib import Path
import aiosqlite
import pytest
from porchlight.store.sqlite.db import open_db
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "porchlight" / "store" / "sqlite" / "migrations"
@pytest.fixture
def migrations_dir() -> Path:
return MIGRATIONS_DIR
async def test_open_db_returns_connection(tmp_path: Path, migrations_dir: Path) -> None:
db_path = str(tmp_path / "test.db")
async with open_db(db_path, migrations_dir) as db:
assert isinstance(db, aiosqlite.Connection)
async def test_open_db_runs_migrations(tmp_path: Path, migrations_dir: Path) -> None:
db_path = str(tmp_path / "test.db")
async with open_db(db_path, migrations_dir) as db:
async with db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") as cursor:
row = await cursor.fetchone()
assert row is not None
async def test_open_db_sets_wal_mode(tmp_path: Path, migrations_dir: Path) -> None:
db_path = str(tmp_path / "test.db")
async with open_db(db_path, migrations_dir) as db:
async with db.execute("PRAGMA journal_mode") as cursor:
row = await cursor.fetchone()
assert row[0] == "wal"
async def test_open_db_creates_parent_dirs(tmp_path: Path, migrations_dir: Path) -> None:
db_path = str(tmp_path / "sub" / "dir" / "test.db")
async with open_db(db_path, migrations_dir) as db:
assert isinstance(db, aiosqlite.Connection)
assert (tmp_path / "sub" / "dir" / "test.db").exists()
```
**Step 2: Run test to verify it fails**
```bash
pytest tests/test_store/test_db.py -v
```
Expected: FAIL — `porchlight.store.sqlite.db` does not exist.
**Step 3: Write the implementation**
```python
# src/porchlight/store/sqlite/db.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import aiosqlite
from porchlight.store.sqlite.migrations import run_migrations
@asynccontextmanager
async def open_db(db_path: str, migrations_dir: Path) -> AsyncIterator[aiosqlite.Connection]:
"""Open a SQLite connection with WAL mode, foreign keys, and migrations applied."""
if db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
db = await aiosqlite.connect(db_path)
try:
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA foreign_keys=ON")
await run_migrations(db, migrations_dir)
yield db
finally:
await db.close()
```
**Step 4: Run test to verify it passes**
```bash
pytest tests/test_store/test_db.py -v
```
Expected: all 4 tests PASS.
**Step 5: Refactor `app.py` lifespan to use `open_db`**
Replace the manual DB setup in `lifespan()` with:
```python
from porchlight.store.sqlite.db import open_db
# In lifespan(), replace lines 36-43 with:
async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db:
# ... rest of setup using db ...
yield
```
The `open_db` context manager handles mkdir, connect, row_factory, PRAGMAs, migrations, and close.
**Step 6: Run full quality gate**
```bash
./scripts/check.sh
```
Expected: all 147 tests pass.
**Step 7: Commit**
```bash
git add src/porchlight/store/sqlite/db.py tests/test_store/test_db.py src/porchlight/app.py
git commit -m "refactor: extract open_db() context manager from lifespan"
```
---
### Task 8: Create CLI module with `create-invite` command
**Files:**
- Create: `src/porchlight/cli.py`
- Test: `tests/test_cli.py`
**Step 1: Write the failing test**
```python
# tests/test_cli.py
from pathlib import Path
from unittest.mock import patch
import pytest
from porchlight.store.sqlite.db import open_db
from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "src" / "porchlight" / "store" / "sqlite" / "migrations"
async def test_create_invite_creates_magic_link(tmp_path: Path) -> None:
from porchlight.cli import _create_invite
db_path = str(tmp_path / "test.db")
async with open_db(db_path, MIGRATIONS_DIR) as db:
result = await _create_invite(db=db, username="alice", issuer="http://localhost:8000")
assert "alice" in result
assert "http://localhost:8000/register?token=" in result
async def test_create_invite_with_ttl(tmp_path: Path) -> None:
from porchlight.cli import _create_invite
db_path = str(tmp_path / "test.db")
async with open_db(db_path, MIGRATIONS_DIR) as db:
result = await _create_invite(db=db, username="bob", issuer="http://localhost:8000", ttl=3600)
assert "bob" in result
assert "http://localhost:8000/register?token=" in result
```
**Step 2: Run test to verify it fails**
```bash
pytest tests/test_cli.py -v
```
Expected: FAIL — `porchlight.cli` does not exist.
**Step 3: Write the implementation**
```python
# src/porchlight/cli.py
import asyncio
from pathlib import Path
import aiosqlite
import typer
from porchlight.config import Settings
from porchlight.invite.service import MagicLinkService
from porchlight.store.sqlite.db import open_db
from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository
PACKAGE_DIR = Path(__file__).parent
MIGRATIONS_DIR = PACKAGE_DIR / "store" / "sqlite" / "migrations"
app = typer.Typer(name="porchlight", help="Porchlight OIDC Provider management CLI.")
async def _create_invite(
db: aiosqlite.Connection,
username: str,
issuer: str,
ttl: int = 86400,
) -> str:
"""Create a magic link invite. Returns a human-readable message with the URL."""
repo = SQLiteMagicLinkRepository(db)
service = MagicLinkService(repo=repo, ttl=ttl)
link = await service.create(username=username, created_by="cli")
url = f"{issuer.rstrip('/')}/register?token={link.token}"
return f"Invite created for: {username}\nRegistration link: {url}"
@app.command()
def create_invite(
username: str = typer.Argument(help="Username for the new invite"),
ttl: int = typer.Option(86400, help="Link expiry in seconds"),
) -> None:
"""Create a magic-link registration invite for a user."""
settings = Settings() # type: ignore[call-arg]
async def _run() -> str:
async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db:
return await _create_invite(db=db, username=username, issuer=settings.issuer, ttl=ttl)
result = asyncio.run(_run())
typer.echo(result)
def main() -> None:
app()
```
**Step 4: Run test to verify it passes**
```bash
pytest tests/test_cli.py::test_create_invite_creates_magic_link tests/test_cli.py::test_create_invite_with_ttl -v
```
Expected: PASS.
**Step 5: Run quality gate**
```bash
./scripts/check.sh
```
**Step 6: Commit**
```bash
git add src/porchlight/cli.py tests/test_cli.py
git commit -m "feat: add create-invite CLI command"
```
---
### Task 9: Add `init-admin` CLI command
**Files:**
- Modify: `src/porchlight/cli.py`
- Modify: `tests/test_cli.py`
**Step 1: Write the failing tests**
Add to `tests/test_cli.py`:
```python
async def test_init_admin_creates_user_and_link(tmp_path: Path) -> None:
from porchlight.cli import _init_admin
db_path = str(tmp_path / "test.db")
async with open_db(db_path, MIGRATIONS_DIR) as db:
result = await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
assert "admin" in result
assert "http://localhost:8000/register?token=" in result
async def test_init_admin_user_in_admin_group(tmp_path: Path) -> None:
from porchlight.cli import _init_admin
db_path = str(tmp_path / "test.db")
async with open_db(db_path, MIGRATIONS_DIR) as db:
await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
from porchlight.store.sqlite.repositories import SQLiteUserRepository
user_repo = SQLiteUserRepository(db)
user = await user_repo.get_by_username("admin")
assert user is not None
assert "admin" in user.groups
async def test_init_admin_duplicate_username_fails(tmp_path: Path) -> None:
from porchlight.cli import _init_admin
db_path = str(tmp_path / "test.db")
async with open_db(db_path, MIGRATIONS_DIR) as db:
await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
with pytest.raises(SystemExit):
await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
```
**Step 2: Run tests to verify they fail**
```bash
pytest tests/test_cli.py -v -k "init_admin"
```
Expected: FAIL — `_init_admin` does not exist.
**Step 3: Write the implementation**
Add to `src/porchlight/cli.py`:
```python
from porchlight.models import User
from porchlight.store.exceptions import DuplicateError
from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository, SQLiteUserRepository
from porchlight.userid import generate_userid
async def _init_admin(
db: aiosqlite.Connection,
username: str,
issuer: str,
ttl: int = 86400,
) -> str:
"""Create an admin user and a registration magic link. Returns message with URL."""
user_repo = SQLiteUserRepository(db)
magic_link_repo = SQLiteMagicLinkRepository(db)
service = MagicLinkService(repo=magic_link_repo, ttl=ttl)
user = User(userid=generate_userid(), username=username, groups=["admin"])
try:
await user_repo.create(user)
except DuplicateError:
typer.echo(f"Error: user '{username}' already exists.", err=True)
raise typer.Exit(code=1)
link = await service.create(username=username, created_by="cli")
url = f"{issuer.rstrip('/')}/register?token={link.token}"
return f"Created admin user: {username}\nRegistration link: {url}"
@app.command()
def init_admin(
username: str = typer.Argument(help="Username for the admin user"),
ttl: int = typer.Option(86400, help="Registration link expiry in seconds"),
) -> None:
"""Create an initial admin user with a registration link."""
settings = Settings() # type: ignore[call-arg]
async def _run() -> str:
async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db:
return await _init_admin(db=db, username=username, issuer=settings.issuer, ttl=ttl)
result = asyncio.run(_run())
typer.echo(result)
```
**Step 4: Run tests to verify they pass**
```bash
pytest tests/test_cli.py -v
```
Expected: all tests PASS.
**Step 5: Run quality gate**
```bash
./scripts/check.sh
```
**Step 6: Commit**
```bash
git add src/porchlight/cli.py tests/test_cli.py
git commit -m "feat: add init-admin CLI command"
```
---
### Task 10: Final verification and Docker rebuild
**Step 1: Run full quality gate**
```bash
./scripts/check.sh
```
Expected: all tests pass (147 original + ~7 new CLI tests), ruff clean, ty clean.
**Step 2: Rebuild Docker images**
```bash
docker build --target prod -t porchlight:prod .
docker build --target dev -t porchlight:dev .
```
Expected: both build successfully.
**Step 3: Verify dev container starts**
```bash
docker compose --profile dev up -d
curl -sf http://localhost:8000/health
docker compose --profile dev down
```
Expected: `{"status":"ok"}`
**Step 4: Verify CLI entry point works in prod image**
```bash
docker run --rm -e OIDC_OP_ISSUER=http://localhost:8000 porchlight:prod uv run porchlight --help
```
Expected: shows typer help with `create-invite` and `init-admin` commands.