# CLI Module Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add `create-invite` and `initial-admin` CLI commands so operators can bootstrap users and generate invite links from the terminal. **Architecture:** A Typer app in `src/porchlight/cli.py` with two commands. Both open the SQLite DB directly via the existing `open_db()` context manager and use `asyncio.run()` for async operations. The `/register/{token}` route is modified to handle pre-existing users (created by `initial-admin`). **Tech Stack:** Typer 0.15+, asyncio, existing SQLite storage layer **Design doc:** `docs/plans/2026-02-17-cli-module-design.md` --- ### Task 1: Make `/register/{token}` handle existing users **Files:** - Modify: `src/porchlight/authn/routes.py:72-90` - Test: `tests/test_auth_routes/test_register_magic_link.py` **Step 1: Write failing test for existing user registration** Add to `tests/test_auth_routes/test_register_magic_link.py`: ```python async def test_register_existing_user_logs_in_and_redirects(client: AsyncClient) -> None: """When initial-admin creates a user, the invite link should log them in.""" app = client._transport.app # type: ignore[union-attr] magic_link_repo = app.state.magic_link_repo user_repo = app.state.user_repo # Pre-create the user (as initial-admin would) from porchlight.models import User user = User(userid="lusab-bansen", username="admin", groups=["admin", "users"]) await user_repo.create(user) # Create invite for the same username await magic_link_repo.create( MagicLink( token="admin-setup", username="admin", expires_at=datetime.now(UTC) + timedelta(hours=1), ) ) res = await client.get("/register/admin-setup", follow_redirects=False) assert res.status_code in (302, 303) assert "/manage/credentials" in res.headers["location"] assert "setup=1" in res.headers["location"] # Token should be marked used link = await magic_link_repo.get_by_token("admin-setup") assert link is not None assert link.used is True # Original user should still exist with original groups existing = await user_repo.get_by_username("admin") assert existing is not None assert existing.userid == "lusab-bansen" assert "admin" in existing.groups ``` **Step 2: Run test to verify it fails** Run: `uv run python -m pytest tests/test_auth_routes/test_register_magic_link.py::test_register_existing_user_logs_in_and_redirects -v` Expected: FAIL -- `DuplicateError` because `user_repo.create()` tries to insert a duplicate username. **Step 3: Modify `register_magic_link` to handle existing users** In `src/porchlight/authn/routes.py`, replace the `register_magic_link` function: ```python @router.get("/register/{token}") async def register_magic_link(request: Request, token: str) -> Response: magic_link_service = request.app.state.magic_link_service user_repo = request.app.state.user_repo link = await magic_link_service.validate(token) if link is None: return HTMLResponse("
Invalid or expired registration link.
", status_code=400) # Check if user already exists (e.g. created by initial-admin) user = await user_repo.get_by_username(link.username) if user is None: userid = await generate_unique_userid(user_repo) user = User(userid=userid, username=link.username, groups=["users"]) await user_repo.create(user) await magic_link_service.mark_used(token) request.session["userid"] = user.userid request.session["username"] = user.username return RedirectResponse("/manage/credentials?setup=1", status_code=303) ``` **Step 4: Run all registration tests** Run: `uv run python -m pytest tests/test_auth_routes/test_register_magic_link.py -v` Expected: ALL PASS **Step 5: Run full test suite to check for regressions** Run: `uv run python -m pytest tests/ --ignore=tests/e2e -v` Expected: ALL PASS (151+ tests) **Step 6: Commit** ``` feat: allow /register/{token} to handle pre-existing users ``` --- ### Task 2: Create CLI module with `create-invite` command **Files:** - Create: `src/porchlight/cli.py` - Test: `tests/test_cli.py` **Step 1: Write failing tests for `create-invite`** Create `tests/test_cli.py`: ```python from unittest.mock import patch from typer.testing import CliRunner from porchlight.cli import app runner = CliRunner() def test_create_invite_prints_url() -> None: """create-invite should print a registration URL.""" with patch.dict( "os.environ", {"OIDC_OP_ISSUER": "http://localhost:8000", "OIDC_OP_SQLITE_PATH": ":memory:"}, ): result = runner.invoke(app, ["create-invite", "alice"]) assert result.exit_code == 0 assert "http://localhost:8000/register/" in result.stdout def test_create_invite_with_note() -> None: """create-invite should accept a --note flag.""" with patch.dict( "os.environ", {"OIDC_OP_ISSUER": "http://localhost:8000", "OIDC_OP_SQLITE_PATH": ":memory:"}, ): result = runner.invoke(app, ["create-invite", "bob", "--note", "Welcome!"]) assert result.exit_code == 0 assert "http://localhost:8000/register/" in result.stdout ``` **Step 2: Run tests to verify they fail** Run: `uv run python -m pytest tests/test_cli.py -v` Expected: FAIL -- `ImportError: cannot import name 'app' from 'porchlight.cli'` **Step 3: Implement `cli.py` with `create-invite`** Create `src/porchlight/cli.py`: ```python import asyncio from pathlib import Path from typing import Annotated 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(help="Porchlight OIDC Provider CLI") @app.command() def create_invite( username: str, note: Annotated[str | None, typer.Option(help="Optional note for the invite")] = None, ttl: Annotated[int | None, typer.Option(help="Link expiration in seconds")] = None, ) -> None: """Generate a magic link registration URL for a new user.""" settings = Settings() # type: ignore[call-arg] asyncio.run(_create_invite(settings, username, note, ttl)) async def _create_invite(settings: Settings, username: str, note: str | None, ttl: int | None) -> None: effective_ttl = ttl if ttl is not None else settings.invite_ttl async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db: repo = SQLiteMagicLinkRepository(db) service = MagicLinkService(repo=repo, ttl=effective_ttl) link = await service.create(username=username, note=note, created_by="cli") url = f"{settings.issuer}/register/{link.token}" typer.echo(url) def main() -> None: app() ``` **Step 4: Run tests to verify they pass** Run: `uv run python -m pytest tests/test_cli.py -v` Expected: ALL PASS **Step 5: Verify the entry point works** Run: `uv run python -m porchlight.cli --help` Expected: Shows help with `create-invite` command listed. **Step 6: Commit** ``` feat: add create-invite CLI command for generating magic link URLs ``` --- ### Task 3: Add `initial-admin` command **Files:** - Modify: `src/porchlight/cli.py` - Modify: `tests/test_cli.py` **Step 1: Write failing tests for `initial-admin`** Add to `tests/test_cli.py`: ```python def test_initial_admin_prints_url() -> None: """initial-admin should create user and print a registration URL.""" with patch.dict( "os.environ", {"OIDC_OP_ISSUER": "http://localhost:8000", "OIDC_OP_SQLITE_PATH": ":memory:"}, ): result = runner.invoke(app, ["initial-admin", "admin"]) assert result.exit_code == 0 assert "http://localhost:8000/register/" in result.stdout def test_initial_admin_duplicate_username_fails() -> None: """initial-admin should fail if the username already exists.""" with patch.dict( "os.environ", {"OIDC_OP_ISSUER": "http://localhost:8000", "OIDC_OP_SQLITE_PATH": ":memory:"}, ): # First call succeeds result1 = runner.invoke(app, ["initial-admin", "admin"]) assert result1.exit_code == 0 # Second call with same username fails result2 = runner.invoke(app, ["initial-admin", "admin"]) assert result2.exit_code == 1 assert "already exists" in result2.stdout def test_initial_admin_custom_groups() -> None: """initial-admin should accept --group flags.""" with patch.dict( "os.environ", {"OIDC_OP_ISSUER": "http://localhost:8000", "OIDC_OP_SQLITE_PATH": ":memory:"}, ): result = runner.invoke(app, ["initial-admin", "admin", "--group", "superadmin", "--group", "users"]) assert result.exit_code == 0 assert "http://localhost:8000/register/" in result.stdout ``` **Step 2: Run tests to verify they fail** Run: `uv run python -m pytest tests/test_cli.py::test_initial_admin_prints_url tests/test_cli.py::test_initial_admin_duplicate_username_fails tests/test_cli.py::test_initial_admin_custom_groups -v` Expected: FAIL -- `initial-admin` command not found. **Step 3: Implement `initial-admin` command** Add imports to `src/porchlight/cli.py`: ```python from porchlight.store.sqlite.repositories import ( SQLiteMagicLinkRepository, SQLiteUserRepository, ) from porchlight.models import User from porchlight.userid import generate_unique_userid ``` Add the command: ```python @app.command() def initial_admin( username: str, group: Annotated[list[str] | None, typer.Option(help="Groups to assign (repeatable)")] = None, ) -> None: """Bootstrap an admin user with a registration link for credential setup.""" settings = Settings() # type: ignore[call-arg] asyncio.run(_initial_admin(settings, username, group)) async def _initial_admin(settings: Settings, username: str, groups: list[str] | None) -> None: effective_groups = groups if groups is not None else ["admin", "users"] async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db: user_repo = SQLiteUserRepository(db) magic_link_repo = SQLiteMagicLinkRepository(db) # Check if username already exists existing = await user_repo.get_by_username(username) if existing is not None: typer.echo(f"Error: user '{username}' already exists", err=True) raise typer.Exit(code=1) # Create the user userid = await generate_unique_userid(user_repo) user = User(userid=userid, username=username, groups=effective_groups) await user_repo.create(user) # Create invite link for credential setup service = MagicLinkService(repo=magic_link_repo, ttl=settings.invite_ttl) link = await service.create(username=username, note="initial admin setup", created_by="cli") url = f"{settings.issuer}/register/{link.token}" typer.echo(url) ``` **Step 4: Run all CLI tests** Run: `uv run python -m pytest tests/test_cli.py -v` Expected: ALL PASS **Step 5: Run full test suite** Run: `uv run python -m pytest tests/ --ignore=tests/e2e -v` Expected: ALL PASS **Step 6: Commit** ``` feat: add initial-admin CLI command for bootstrapping admin users ``` --- ### Task 4: Run full quality check **Step 1: Format and lint** Run: `uv run python -m ruff format src/porchlight/cli.py tests/test_cli.py && uv run python -m ruff check src/porchlight/cli.py tests/test_cli.py --fix` **Step 2: Type check** Run: `uv run ty check src/porchlight/cli.py` **Step 3: Run all tests** Run: `uv run python -m pytest tests/ --ignore=tests/e2e -v` Expected: ALL PASS **Step 4: Fix any issues found** If any lint, type, or test failures occur, fix them before committing. **Step 5: Final commit if any fixes needed** ``` refactor: fix lint/type issues from CLI module changes ```