docs: add rename and CLI implementation plan
This commit is contained in:
parent
5d97e496f1
commit
32b75cf92d
1 changed files with 645 additions and 0 deletions
645
docs/plans/2026-02-16-rename-and-cli-plan.md
Normal file
645
docs/plans/2026-02-16-rename-and-cli-plan.md
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
# Rename to Porchlight + CLI Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Rename the project from `fastapi-oidc-op` / `fastapi_oidc_op` to `porchlight` and add a typer-based CLI with `init-admin` and `create-invite` commands.
|
||||
|
||||
**Architecture:** The rename is a mechanical find-and-replace across the entire codebase (package dir, imports, pyproject.toml, Dockerfile). The CLI adds `typer>=0.15` as a dependency, a shared `open_db()` context manager extracted from the lifespan DB setup, and a `cli.py` module with two async command implementations bridged to sync via `asyncio.run()`.
|
||||
|
||||
**Tech Stack:** Python 3.13, typer, aiosqlite, pydantic-settings
|
||||
|
||||
**Quality gate:** `./scripts/check.sh` (ruff format, ruff check, ty check, pytest — 147 tests)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rename package directory
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/fastapi_oidc_op/` → `src/porchlight/`
|
||||
|
||||
**Step 1: Move the directory**
|
||||
|
||||
```bash
|
||||
git mv src/fastapi_oidc_op src/porchlight
|
||||
```
|
||||
|
||||
**Step 2: Verify directory exists**
|
||||
|
||||
```bash
|
||||
ls src/porchlight/app.py
|
||||
```
|
||||
|
||||
Expected: file exists.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "refactor: rename package directory fastapi_oidc_op to porchlight"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update pyproject.toml
|
||||
|
||||
**Files:**
|
||||
- Modify: `pyproject.toml` (lines 2, 24, 31)
|
||||
|
||||
**Step 1: Update project name, script entry point, and hatch packages**
|
||||
|
||||
Change these three lines:
|
||||
|
||||
```
|
||||
line 2: name = "fastapi-oidc-op" → name = "porchlight"
|
||||
line 24: fastapi-oidc-op = "fastapi_oidc_op.cli:main" → porchlight = "porchlight.cli:main"
|
||||
line 31: packages = ["src/fastapi_oidc_op"] → packages = ["src/porchlight"]
|
||||
```
|
||||
|
||||
**Step 2: Add typer dependency**
|
||||
|
||||
Add `"typer>=0.15"` to the `dependencies` list.
|
||||
|
||||
**Step 3: Regenerate lock file**
|
||||
|
||||
```bash
|
||||
uv lock
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add pyproject.toml uv.lock && git commit -m "refactor: update pyproject.toml for porchlight rename and add typer dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update all imports in source files
|
||||
|
||||
**Files:**
|
||||
- Modify all `.py` files under `src/porchlight/` that contain `fastapi_oidc_op`
|
||||
|
||||
The following files need `fastapi_oidc_op` replaced with `porchlight` in all import statements:
|
||||
|
||||
- `src/porchlight/app.py` (lines 13-22: 10 imports)
|
||||
- `src/porchlight/config.py` (line 1: comment only)
|
||||
- `src/porchlight/dependencies.py` (line 3)
|
||||
- `src/porchlight/authn/routes.py` (lines 10-11)
|
||||
- `src/porchlight/invite/service.py` (lines 4-5)
|
||||
- `src/porchlight/manage/routes.py` (lines 7-8)
|
||||
- `src/porchlight/oidc/claims.py` (line 5)
|
||||
- `src/porchlight/oidc/endpoints.py` (line 13)
|
||||
- `src/porchlight/oidc/provider.py` (lines 7-8)
|
||||
- `src/porchlight/store/protocols.py` (line 3)
|
||||
- `src/porchlight/store/sqlite/repositories.py` (lines 5-6)
|
||||
- `src/porchlight/userid.py` (line 5)
|
||||
|
||||
**Step 1: Replace all occurrences**
|
||||
|
||||
In every `.py` file under `src/porchlight/`, replace `fastapi_oidc_op` with `porchlight`.
|
||||
|
||||
**Step 2: Verify no stale references remain**
|
||||
|
||||
```bash
|
||||
grep -r "fastapi_oidc_op" src/
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ && git commit -m "refactor: update all source imports from fastapi_oidc_op to porchlight"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update all imports in test files
|
||||
|
||||
**Files:**
|
||||
- Modify all `.py` files under `tests/` that contain `fastapi_oidc_op`
|
||||
|
||||
Files with import references to update (`fastapi_oidc_op` → `porchlight`):
|
||||
|
||||
- `tests/conftest.py` (lines 6-7)
|
||||
- `tests/test_app.py` (lines 19, 34)
|
||||
- `tests/test_config.py` (line 4)
|
||||
- `tests/test_models.py` (line 3)
|
||||
- `tests/test_userid.py` (lines 5-6)
|
||||
- `tests/e2e/setup_db.py` (lines 17-20)
|
||||
- `tests/test_auth_routes/test_last_credential_guard.py` (lines 7-8)
|
||||
- `tests/test_auth_routes/test_manage_credentials_page.py` (lines 6-7)
|
||||
- `tests/test_auth_routes/test_manage_password_credential.py` (lines 6-7)
|
||||
- `tests/test_auth_routes/test_manage_webauthn_credential.py` (lines 20-21)
|
||||
- `tests/test_auth_routes/test_password_login.py` (lines 6-7)
|
||||
- `tests/test_auth_routes/test_register_magic_link.py` (line 5)
|
||||
- `tests/test_auth_routes/test_session_deps.py` (line 6)
|
||||
- `tests/test_auth_routes/test_webauthn_login.py` (line 12)
|
||||
- `tests/test_authn/test_password.py` (line 3)
|
||||
- `tests/test_authn/test_webauthn.py` (line 21)
|
||||
- `tests/test_invite/test_service.py` (lines 7-9, 12 — note line 12 has a Path string)
|
||||
- `tests/test_oidc/test_claims.py` (lines 3-4)
|
||||
- `tests/test_oidc/test_e2e_flow.py` (lines 12-13)
|
||||
- `tests/test_oidc/test_login_oidc_redirect.py` (lines 7-8)
|
||||
- `tests/test_oidc/test_provider.py` (lines 4-5, 52)
|
||||
- `tests/test_oidc/test_token.py` (lines 9-10)
|
||||
- `tests/test_oidc/test_userinfo.py` (lines 9-10)
|
||||
- `tests/test_store/conftest.py` (lines 6, 9 — note line 9 has a Path string)
|
||||
- `tests/test_store/test_exceptions.py` (line 1)
|
||||
- `tests/test_store/test_migrations.py` (lines 5, 8 — note line 8 has a Path string)
|
||||
- `tests/test_store/test_protocols.py` (line 3)
|
||||
- `tests/test_store/test_sqlite_credential_repo.py` (lines 4-7)
|
||||
- `tests/test_store/test_sqlite_magic_link_repo.py` (lines 6-9)
|
||||
- `tests/test_store/test_sqlite_user_repo.py` (lines 4-7)
|
||||
|
||||
**Important:** Three files have `Path(...)` strings containing `"fastapi_oidc_op"` that also need updating:
|
||||
- `tests/test_invite/test_service.py` line 12: `"src" / "fastapi_oidc_op" / "store"` → `"src" / "porchlight" / "store"`
|
||||
- `tests/test_store/conftest.py` line 9: same pattern
|
||||
- `tests/test_store/test_migrations.py` line 8: same pattern
|
||||
|
||||
**Step 1: Replace all occurrences**
|
||||
|
||||
In every `.py` file under `tests/`, replace `fastapi_oidc_op` with `porchlight` (both in imports and string literals).
|
||||
|
||||
**Step 2: Verify no stale references remain**
|
||||
|
||||
```bash
|
||||
grep -r "fastapi_oidc_op" tests/
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/ && git commit -m "refactor: update all test imports from fastapi_oidc_op to porchlight"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update Dockerfile
|
||||
|
||||
**Files:**
|
||||
- Modify: `Dockerfile` (lines 29, 45)
|
||||
|
||||
**Step 1: Replace module references**
|
||||
|
||||
```
|
||||
line 29: "fastapi_oidc_op.app:create_app" → "porchlight.app:create_app"
|
||||
line 45: "fastapi_oidc_op.app:create_app" → "porchlight.app:create_app"
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Dockerfile && git commit -m "refactor: update Dockerfile for porchlight rename"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Run quality gate to verify rename
|
||||
|
||||
**Step 1: Run full quality gate**
|
||||
|
||||
```bash
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
Expected: all 147 tests pass, ruff clean, ty clean.
|
||||
|
||||
**Step 2: Verify no remaining references**
|
||||
|
||||
```bash
|
||||
grep -r "fastapi_oidc_op" src/ tests/ Dockerfile pyproject.toml docker-compose.yml
|
||||
```
|
||||
|
||||
Expected: no output (docker-compose.yml has no package refs, only env vars with `OIDC_OP_` prefix which we keep).
|
||||
|
||||
**Step 3: If anything fails, fix and amend the relevant commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Extract `open_db()` context manager
|
||||
|
||||
**Files:**
|
||||
- Create: `src/porchlight/store/sqlite/db.py`
|
||||
- Test: `tests/test_store/test_db.py`
|
||||
- Modify: `src/porchlight/app.py` (refactor lifespan to use `open_db`)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_store/test_db.py
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from porchlight.store.sqlite.db import open_db
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "porchlight" / "store" / "sqlite" / "migrations"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migrations_dir() -> Path:
|
||||
return MIGRATIONS_DIR
|
||||
|
||||
|
||||
async def test_open_db_returns_connection(tmp_path: Path, migrations_dir: Path) -> None:
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, migrations_dir) as db:
|
||||
assert isinstance(db, aiosqlite.Connection)
|
||||
|
||||
|
||||
async def test_open_db_runs_migrations(tmp_path: Path, migrations_dir: Path) -> None:
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, migrations_dir) as db:
|
||||
async with db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
|
||||
|
||||
async def test_open_db_sets_wal_mode(tmp_path: Path, migrations_dir: Path) -> None:
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, migrations_dir) as db:
|
||||
async with db.execute("PRAGMA journal_mode") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
assert row[0] == "wal"
|
||||
|
||||
|
||||
async def test_open_db_creates_parent_dirs(tmp_path: Path, migrations_dir: Path) -> None:
|
||||
db_path = str(tmp_path / "sub" / "dir" / "test.db")
|
||||
async with open_db(db_path, migrations_dir) as db:
|
||||
assert isinstance(db, aiosqlite.Connection)
|
||||
assert (tmp_path / "sub" / "dir" / "test.db").exists()
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
pytest tests/test_store/test_db.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `porchlight.store.sqlite.db` does not exist.
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```python
|
||||
# src/porchlight/store/sqlite/db.py
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from porchlight.store.sqlite.migrations import run_migrations
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_db(db_path: str, migrations_dir: Path) -> AsyncIterator[aiosqlite.Connection]:
|
||||
"""Open a SQLite connection with WAL mode, foreign keys, and migrations applied."""
|
||||
if db_path != ":memory:":
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
db = await aiosqlite.connect(db_path)
|
||||
try:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA foreign_keys=ON")
|
||||
await run_migrations(db, migrations_dir)
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
pytest tests/test_store/test_db.py -v
|
||||
```
|
||||
|
||||
Expected: all 4 tests PASS.
|
||||
|
||||
**Step 5: Refactor `app.py` lifespan to use `open_db`**
|
||||
|
||||
Replace the manual DB setup in `lifespan()` with:
|
||||
|
||||
```python
|
||||
from porchlight.store.sqlite.db import open_db
|
||||
|
||||
# In lifespan(), replace lines 36-43 with:
|
||||
async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db:
|
||||
# ... rest of setup using db ...
|
||||
yield
|
||||
```
|
||||
|
||||
The `open_db` context manager handles mkdir, connect, row_factory, PRAGMAs, migrations, and close.
|
||||
|
||||
**Step 6: Run full quality gate**
|
||||
|
||||
```bash
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
Expected: all 147 tests pass.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/porchlight/store/sqlite/db.py tests/test_store/test_db.py src/porchlight/app.py
|
||||
git commit -m "refactor: extract open_db() context manager from lifespan"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Create CLI module with `create-invite` command
|
||||
|
||||
**Files:**
|
||||
- Create: `src/porchlight/cli.py`
|
||||
- Test: `tests/test_cli.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_cli.py
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from porchlight.store.sqlite.db import open_db
|
||||
from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "src" / "porchlight" / "store" / "sqlite" / "migrations"
|
||||
|
||||
|
||||
async def test_create_invite_creates_magic_link(tmp_path: Path) -> None:
|
||||
from porchlight.cli import _create_invite
|
||||
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, MIGRATIONS_DIR) as db:
|
||||
result = await _create_invite(db=db, username="alice", issuer="http://localhost:8000")
|
||||
|
||||
assert "alice" in result
|
||||
assert "http://localhost:8000/register?token=" in result
|
||||
|
||||
|
||||
async def test_create_invite_with_ttl(tmp_path: Path) -> None:
|
||||
from porchlight.cli import _create_invite
|
||||
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, MIGRATIONS_DIR) as db:
|
||||
result = await _create_invite(db=db, username="bob", issuer="http://localhost:8000", ttl=3600)
|
||||
|
||||
assert "bob" in result
|
||||
assert "http://localhost:8000/register?token=" in result
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
pytest tests/test_cli.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `porchlight.cli` does not exist.
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```python
|
||||
# src/porchlight/cli.py
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
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(name="porchlight", help="Porchlight OIDC Provider management CLI.")
|
||||
|
||||
|
||||
async def _create_invite(
|
||||
db: aiosqlite.Connection,
|
||||
username: str,
|
||||
issuer: str,
|
||||
ttl: int = 86400,
|
||||
) -> str:
|
||||
"""Create a magic link invite. Returns a human-readable message with the URL."""
|
||||
repo = SQLiteMagicLinkRepository(db)
|
||||
service = MagicLinkService(repo=repo, ttl=ttl)
|
||||
link = await service.create(username=username, created_by="cli")
|
||||
url = f"{issuer.rstrip('/')}/register?token={link.token}"
|
||||
return f"Invite created for: {username}\nRegistration link: {url}"
|
||||
|
||||
|
||||
@app.command()
|
||||
def create_invite(
|
||||
username: str = typer.Argument(help="Username for the new invite"),
|
||||
ttl: int = typer.Option(86400, help="Link expiry in seconds"),
|
||||
) -> None:
|
||||
"""Create a magic-link registration invite for a user."""
|
||||
settings = Settings() # type: ignore[call-arg]
|
||||
|
||||
async def _run() -> str:
|
||||
async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db:
|
||||
return await _create_invite(db=db, username=username, issuer=settings.issuer, ttl=ttl)
|
||||
|
||||
result = asyncio.run(_run())
|
||||
typer.echo(result)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app()
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
pytest tests/test_cli.py::test_create_invite_creates_magic_link tests/test_cli.py::test_create_invite_with_ttl -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Run quality gate**
|
||||
|
||||
```bash
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/porchlight/cli.py tests/test_cli.py
|
||||
git commit -m "feat: add create-invite CLI command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Add `init-admin` CLI command
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/porchlight/cli.py`
|
||||
- Modify: `tests/test_cli.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_cli.py`:
|
||||
|
||||
```python
|
||||
async def test_init_admin_creates_user_and_link(tmp_path: Path) -> None:
|
||||
from porchlight.cli import _init_admin
|
||||
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, MIGRATIONS_DIR) as db:
|
||||
result = await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
|
||||
|
||||
assert "admin" in result
|
||||
assert "http://localhost:8000/register?token=" in result
|
||||
|
||||
|
||||
async def test_init_admin_user_in_admin_group(tmp_path: Path) -> None:
|
||||
from porchlight.cli import _init_admin
|
||||
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, MIGRATIONS_DIR) as db:
|
||||
await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
|
||||
|
||||
from porchlight.store.sqlite.repositories import SQLiteUserRepository
|
||||
user_repo = SQLiteUserRepository(db)
|
||||
user = await user_repo.get_by_username("admin")
|
||||
|
||||
assert user is not None
|
||||
assert "admin" in user.groups
|
||||
|
||||
|
||||
async def test_init_admin_duplicate_username_fails(tmp_path: Path) -> None:
|
||||
from porchlight.cli import _init_admin
|
||||
|
||||
db_path = str(tmp_path / "test.db")
|
||||
async with open_db(db_path, MIGRATIONS_DIR) as db:
|
||||
await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
await _init_admin(db=db, username="admin", issuer="http://localhost:8000")
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
pytest tests/test_cli.py -v -k "init_admin"
|
||||
```
|
||||
|
||||
Expected: FAIL — `_init_admin` does not exist.
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
Add to `src/porchlight/cli.py`:
|
||||
|
||||
```python
|
||||
from porchlight.models import User
|
||||
from porchlight.store.exceptions import DuplicateError
|
||||
from porchlight.store.sqlite.repositories import SQLiteMagicLinkRepository, SQLiteUserRepository
|
||||
from porchlight.userid import generate_userid
|
||||
|
||||
|
||||
async def _init_admin(
|
||||
db: aiosqlite.Connection,
|
||||
username: str,
|
||||
issuer: str,
|
||||
ttl: int = 86400,
|
||||
) -> str:
|
||||
"""Create an admin user and a registration magic link. Returns message with URL."""
|
||||
user_repo = SQLiteUserRepository(db)
|
||||
magic_link_repo = SQLiteMagicLinkRepository(db)
|
||||
service = MagicLinkService(repo=magic_link_repo, ttl=ttl)
|
||||
|
||||
user = User(userid=generate_userid(), username=username, groups=["admin"])
|
||||
try:
|
||||
await user_repo.create(user)
|
||||
except DuplicateError:
|
||||
typer.echo(f"Error: user '{username}' already exists.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
link = await service.create(username=username, created_by="cli")
|
||||
url = f"{issuer.rstrip('/')}/register?token={link.token}"
|
||||
return f"Created admin user: {username}\nRegistration link: {url}"
|
||||
|
||||
|
||||
@app.command()
|
||||
def init_admin(
|
||||
username: str = typer.Argument(help="Username for the admin user"),
|
||||
ttl: int = typer.Option(86400, help="Registration link expiry in seconds"),
|
||||
) -> None:
|
||||
"""Create an initial admin user with a registration link."""
|
||||
settings = Settings() # type: ignore[call-arg]
|
||||
|
||||
async def _run() -> str:
|
||||
async with open_db(settings.sqlite_path, MIGRATIONS_DIR) as db:
|
||||
return await _init_admin(db=db, username=username, issuer=settings.issuer, ttl=ttl)
|
||||
|
||||
result = asyncio.run(_run())
|
||||
typer.echo(result)
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
pytest tests/test_cli.py -v
|
||||
```
|
||||
|
||||
Expected: all tests PASS.
|
||||
|
||||
**Step 5: Run quality gate**
|
||||
|
||||
```bash
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/porchlight/cli.py tests/test_cli.py
|
||||
git commit -m "feat: add init-admin CLI command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Final verification and Docker rebuild
|
||||
|
||||
**Step 1: Run full quality gate**
|
||||
|
||||
```bash
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
Expected: all tests pass (147 original + ~7 new CLI tests), ruff clean, ty clean.
|
||||
|
||||
**Step 2: Rebuild Docker images**
|
||||
|
||||
```bash
|
||||
docker build --target prod -t porchlight:prod .
|
||||
docker build --target dev -t porchlight:dev .
|
||||
```
|
||||
|
||||
Expected: both build successfully.
|
||||
|
||||
**Step 3: Verify dev container starts**
|
||||
|
||||
```bash
|
||||
docker compose --profile dev up -d
|
||||
curl -sf http://localhost:8000/health
|
||||
docker compose --profile dev down
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok"}`
|
||||
|
||||
**Step 4: Verify CLI entry point works in prod image**
|
||||
|
||||
```bash
|
||||
docker run --rm -e OIDC_OP_ISSUER=http://localhost:8000 porchlight:prod uv run porchlight --help
|
||||
```
|
||||
|
||||
Expected: shows typer help with `create-invite` and `init-admin` commands.
|
||||
Loading…
Add table
Add a link
Reference in a new issue