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