373 lines
12 KiB
Markdown
373 lines
12 KiB
Markdown
# 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("<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`:
|
|
|
|
```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
|
|
```
|