docs: add CLI module design and implementation plan
This commit is contained in:
parent
cd9469342b
commit
a817fdb0f6
2 changed files with 446 additions and 0 deletions
73
docs/plans/2026-02-17-cli-module-design.md
Normal file
73
docs/plans/2026-02-17-cli-module-design.md
Normal file
|
|
@ -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 <username>`
|
||||
|
||||
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 <username>`
|
||||
|
||||
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 |
|
||||
373
docs/plans/2026-02-17-cli-module-plan.md
Normal file
373
docs/plans/2026-02-17-cli-module-plan.md
Normal file
|
|
@ -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("<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
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue