porchlight/docs/plans/2026-02-17-cli-module-plan.md
2026-02-17 14:09:14 +01:00

12 KiB

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:

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:

@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("<p>Invalid or expired registration link.</p>", 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:

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:

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:

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:

from porchlight.store.sqlite.repositories import (
    SQLiteMagicLinkRepository,
    SQLiteUserRepository,
)
from porchlight.models import User
from porchlight.userid import generate_unique_userid

Add the command:

@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