18 KiB
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
git mv src/fastapi_oidc_op src/porchlight
Step 2: Verify directory exists
ls src/porchlight/app.py
Expected: file exists.
Step 3: Commit
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
uv lock
Step 4: Commit
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
.pyfiles undersrc/porchlight/that containfastapi_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
grep -r "fastapi_oidc_op" src/
Expected: no output.
Step 3: Commit
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
.pyfiles undertests/that containfastapi_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.pyline 12:"src" / "fastapi_oidc_op" / "store"→"src" / "porchlight" / "store"tests/test_store/conftest.pyline 9: same patterntests/test_store/test_migrations.pyline 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
grep -r "fastapi_oidc_op" tests/
Expected: no output.
Step 3: Commit
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
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
./scripts/check.sh
Expected: all 147 tests pass, ruff clean, ty clean.
Step 2: Verify no remaining references
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 useopen_db)
Step 1: Write the failing test
# 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
pytest tests/test_store/test_db.py -v
Expected: FAIL — porchlight.store.sqlite.db does not exist.
Step 3: Write the implementation
# 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
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:
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
./scripts/check.sh
Expected: all 147 tests pass.
Step 7: Commit
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
# 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
pytest tests/test_cli.py -v
Expected: FAIL — porchlight.cli does not exist.
Step 3: Write the implementation
# 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
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
./scripts/check.sh
Step 6: Commit
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:
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
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:
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
pytest tests/test_cli.py -v
Expected: all tests PASS.
Step 5: Run quality gate
./scripts/check.sh
Step 6: Commit
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
./scripts/check.sh
Expected: all tests pass (147 original + ~7 new CLI tests), ruff clean, ty clean.
Step 2: Rebuild Docker images
docker build --target prod -t porchlight:prod .
docker build --target dev -t porchlight:dev .
Expected: both build successfully.
Step 3: Verify dev container starts
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
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.