porchlight/docs/plans/2026-02-16-rename-and-cli-plan.md
2026-02-16 15:26:25 +01:00

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 .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

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 .py files under tests/ that contain fastapi_oidc_op

Files with import references to update (fastapi_oidc_opporchlight):

  • 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

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 use open_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.