diff --git a/docs/plans/2026-02-17-cli-module-design.md b/docs/plans/2026-02-17-cli-module-design.md new file mode 100644 index 0000000..427787e --- /dev/null +++ b/docs/plans/2026-02-17-cli-module-design.md @@ -0,0 +1,73 @@ +# CLI Module Design + +## Problem + +`pyproject.toml` declares `porchlight = "porchlight.cli:main"` with `typer>=0.15` as a +dependency, but no `cli.py` exists. The app has no CLI entry point for administrative +tasks -- operators must use the Python shell or raw SQL to create invite links or +bootstrap initial users. + +## Decision + +Create `src/porchlight/cli.py` with two commands: `create-invite` and `initial-admin`. +Skip a `serve` command since Docker/uvicorn handles that already. + +## Commands + +### `porchlight create-invite ` + +Generate a magic link registration URL for a new user. + +Options: +- `--ttl SECONDS` -- link expiration (default: from settings, 86400s) +- `--note TEXT` -- optional note stored with the link + +Behavior: +- Opens SQLite DB via existing `open_db()` context manager +- Creates `MagicLinkService` with `SQLiteMagicLinkRepository` +- Calls `service.create(username=username, note=note, created_by="cli")` +- Prints full URL: `{issuer}/register/{token}` +- Reads `OIDC_OP_ISSUER` env var for base URL (required) +- Reads `OIDC_OP_SQLITE_PATH` for DB path (default: `data/oidc_op.db`) + +### `porchlight initial-admin ` + +Bootstrap the first admin user with a registration link. + +Options: +- `--group TEXT` -- groups to assign (default: `["admin", "users"]`), repeatable + +Behavior: +- Opens SQLite DB +- Checks if username already exists -- error if so +- Generates unique userid via `generate_unique_userid()` +- Creates user with specified groups +- Creates a magic link so the admin can visit the URL to set up credentials +- Prints full URL: `{issuer}/register/{token}` + +## Route change: `/register/{token}` + +Modify `register_magic_link` to handle existing users: + +- Before creating the user, call `user_repo.get_by_username(link.username)` +- If user exists: skip creation, log them in, redirect to `/manage/credentials?setup=1` +- If user doesn't exist: create as before with `groups=["users"]` + +This makes the registration route work for both fresh invites and admin-created users +who need to set up credentials. + +## Implementation notes + +- Both commands use `asyncio.run()` to call async helpers +- Config loaded via `Settings()` (pydantic-settings reads env vars) +- No new dependencies required -- Typer is already declared +- No schema changes needed + +## Files changed + +| File | Change | +|------|--------| +| `src/porchlight/cli.py` | New -- Typer app with two commands | +| `src/porchlight/authn/routes.py` | Modify `register_magic_link` for existing users | +| `tests/test_cli.py` | New -- tests for both CLI commands | +| `tests/test_auth_routes/test_register_magic_link.py` | Add test for existing user registration | diff --git a/docs/plans/2026-02-17-cli-module-plan.md b/docs/plans/2026-02-17-cli-module-plan.md new file mode 100644 index 0000000..a7ddcda --- /dev/null +++ b/docs/plans/2026-02-17-cli-module-plan.md @@ -0,0 +1,373 @@ +# 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 +```