diff --git a/docs/plans/2026-02-16-rename-and-cli-plan.md b/docs/plans/2026-02-16-rename-and-cli-plan.md new file mode 100644 index 0000000..5752ecf --- /dev/null +++ b/docs/plans/2026-02-16-rename-and-cli-plan.md @@ -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.