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