Compare commits
3 Commits
0477392b5e
...
7e3bab4e0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e3bab4e0c | ||
|
|
caa9951dc8 | ||
|
|
b8158e260a |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
1930
docs/superpowers/plans/2026-06-23-openvpncertupdate.md
Normal file
1930
docs/superpowers/plans/2026-06-23-openvpncertupdate.md
Normal file
File diff suppressed because it is too large
Load Diff
425
docs/superpowers/plans/2026-07-08-cli-list-certs.md
Normal file
425
docs/superpowers/plans/2026-07-08-cli-list-certs.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# CLI --list / --list-all Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add two non-interactive CLI flags, `--list` and `--list-all`, that print an aligned CN/expiry/email table to stdout — `--list` for the same recently-expired/soon-to-expire window the TUI's default view uses, `--list-all` for every valid cert.
|
||||
|
||||
**Architecture:** A pure formatting function (`_format_cert_table()`) turns a `list[CertInfo]` into an aligned table string, reusing the existing `_expiry_label()` (TUI wording) and `get_email()` (index.txt-backed lookup, no metadata file). A new `CliRunner.list_certs(show_all: bool)` method picks `load_expiring_certs()` or `load_all_certs()` and prints the table. `main()` dispatches the two new mutually-exclusive flags to it, and — since listing never touches the CA private key — skips `resolve_ca_passphrase()` entirely for this path instead of prompting needlessly.
|
||||
|
||||
**Tech Stack:** Python 3.9, stdlib `argparse`, existing `CertInfo`/`load_all_certs`/`load_expiring_certs`/`get_email`/`_expiry_label` from `openvpncertupdate.py`. Tests via `pytest` + `unittest.mock`, matching the patterns already used in `tests/test_cli.py`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Single-file script: all production code stays in `openvpncertupdate.py`; no new modules.
|
||||
- Follow existing `CliRunner` conventions: business logic prints via `print()`/`sys.stderr`, no return values needed by callers.
|
||||
- `DAYS_PAST` / `DAYS_AHEAD` (SETTINGS constants, currently 30 / 14) define the `--list` window — do not hardcode different values.
|
||||
- No email storage beyond `index.txt` (established in the prior session's refactor) — `get_email()` is the only lookup path, do not reintroduce a metadata file.
|
||||
- Column layout for the table: `CN`, `EXPIRES` (from `_expiry_label()`, whitespace-stripped), `EMAIL` (from `get_email()`, or the literal string `(none)` when empty) — two spaces between columns, dynamically sized to the data, header row included. Empty cert list prints the literal string `(no certificates)`.
|
||||
- `--list`/`--list-all` must be mutually exclusive with each other and with `--create`/`--reissue`/`--revoke`/`--gen-crl` (same `argparse` group), and must NOT trigger `resolve_ca_passphrase()`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify `openvpncertupdate.py`:**
|
||||
- Add `_format_cert_table()` — new function, CLI section, directly above `class CliRunner:`.
|
||||
- Add `CliRunner.list_certs()` — new method on the existing class.
|
||||
- Modify `_build_parser()` — add `--list` / `--list-all` to the existing mutually-exclusive group.
|
||||
- Modify `main()` — dispatch the two new flags, and restructure so CA-passphrase resolution happens after (not before) that dispatch check, since list actions return early.
|
||||
- **Modify `tests/test_cli.py`:** add a new "list_certs / _format_cert_table" section following the file's existing conventions (`_patch_issue`-style monkeypatching, `sys.argv` dispatch tests).
|
||||
- **Modify `CLAUDE.md`:** add the two flags to the "CLI flags" table, add example invocations to "Running", and note the CA-passphrase-resolution exception in "Key constraints".
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `_format_cert_table()` formatting helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `openvpncertupdate.py` (insert new function directly above `class CliRunner:`, currently at line 1265 — search for `# === CLI ===` section header)
|
||||
- Test: `tests/test_cli.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CertInfo` (fields: `cn: str`, `expires: datetime`, `days_left: int`, `email: str = ""`), `_expiry_label(c: CertInfo) -> str`, `get_email(pki_dir: str, cn: str) -> str`, module global `EASYRSA_PKI_DIR: str`.
|
||||
- Produces: `_format_cert_table(certs: list[CertInfo]) -> str` — used by Task 2.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_cli.py`, in a new section after the existing `CliRunner.revoke` / `CliRunner.regen_crl` tests and before `main() argument dispatch`:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_cert_table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_format_cert_table_empty_list():
|
||||
import openvpncertupdate
|
||||
assert openvpncertupdate._format_cert_table([]) == "(no certificates)"
|
||||
|
||||
|
||||
def test_format_cert_table_header_and_columns(monkeypatch):
|
||||
import openvpncertupdate
|
||||
certs = [
|
||||
openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24),
|
||||
openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"openvpncertupdate.get_email",
|
||||
MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")),
|
||||
)
|
||||
table = openvpncertupdate._format_cert_table(certs)
|
||||
lines = table.splitlines()
|
||||
assert lines[0].split() == ["CN", "EXPIRES", "EMAIL"]
|
||||
assert len(lines) == 3
|
||||
|
||||
|
||||
def test_format_cert_table_shows_email_and_none_placeholder(monkeypatch):
|
||||
import openvpncertupdate
|
||||
certs = [
|
||||
openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24),
|
||||
openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"openvpncertupdate.get_email",
|
||||
MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")),
|
||||
)
|
||||
table = openvpncertupdate._format_cert_table(certs)
|
||||
lines = table.splitlines()
|
||||
assert "alice@example.com" in lines[1]
|
||||
assert "(none)" in lines[2]
|
||||
|
||||
|
||||
def test_format_cert_table_columns_are_aligned(monkeypatch):
|
||||
import openvpncertupdate
|
||||
certs = [
|
||||
openvpncertupdate.CertInfo(cn="a", expires=None, days_left=24),
|
||||
openvpncertupdate.CertInfo(cn="a-much-longer", expires=None, days_left=5),
|
||||
]
|
||||
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value=""))
|
||||
table = openvpncertupdate._format_cert_table(certs)
|
||||
lines = table.splitlines()
|
||||
# The EMAIL column must start at the same character offset on every row.
|
||||
email_col = len(lines[0]) - len("EMAIL")
|
||||
for line in lines[1:]:
|
||||
assert line.rstrip().endswith("(none)")
|
||||
assert line.index("(none)") == email_col
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `python3 -m pytest tests/test_cli.py -k format_cert_table -v`
|
||||
Expected: FAIL — `AttributeError: module 'openvpncertupdate' has no attribute '_format_cert_table'`
|
||||
|
||||
- [ ] **Step 3: Implement `_format_cert_table()`**
|
||||
|
||||
In `openvpncertupdate.py`, insert directly above `class CliRunner:` (i.e. right after the `# === CLI ===` section header block and before `class CliRunner:`):
|
||||
|
||||
```python
|
||||
def _format_cert_table(certs: list[CertInfo]) -> str:
|
||||
"""Render CN / expiry / email as an aligned table, like `ls -l`."""
|
||||
if not certs:
|
||||
return "(no certificates)"
|
||||
rows = [
|
||||
(c.cn, _expiry_label(c).strip(), get_email(EASYRSA_PKI_DIR, c.cn) or "(none)")
|
||||
for c in certs
|
||||
]
|
||||
cn_w = max(len("CN"), max(len(r[0]) for r in rows))
|
||||
exp_w = max(len("EXPIRES"), max(len(r[1]) for r in rows))
|
||||
lines = [f"{'CN':<{cn_w}} {'EXPIRES':<{exp_w}} EMAIL"]
|
||||
for cn, exp, email in rows:
|
||||
lines.append(f"{cn:<{cn_w}} {exp:<{exp_w}} {email}")
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `python3 -m pytest tests/test_cli.py -k format_cert_table -v`
|
||||
Expected: 4 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add openvpncertupdate.py tests/test_cli.py
|
||||
git commit -m "add _format_cert_table() helper for CLI cert listings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `CliRunner.list_certs()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `openvpncertupdate.py` (add method to `class CliRunner`, currently spanning from `def create(...)` through `def _issue(...)`)
|
||||
- Test: `tests/test_cli.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `_format_cert_table(certs: list[CertInfo]) -> str` (Task 1), `load_expiring_certs(pki_dir: str, days_past: int, days_ahead: int) -> list[CertInfo]`, `load_all_certs(pki_dir: str) -> list[CertInfo]`, module globals `EASYRSA_PKI_DIR`, `DAYS_PAST`, `DAYS_AHEAD`.
|
||||
- Produces: `CliRunner.list_certs(self, show_all: bool) -> None` — used by Task 3's `main()` dispatch.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_cli.py`, directly after the `_format_cert_table` tests from Task 1:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# CliRunner.list_certs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_certs_expiring_uses_days_settings(monkeypatch, capsys):
|
||||
mock_load = MagicMock(return_value=[])
|
||||
monkeypatch.setattr("openvpncertupdate.load_expiring_certs", mock_load)
|
||||
monkeypatch.setattr("openvpncertupdate.DAYS_PAST", 30)
|
||||
monkeypatch.setattr("openvpncertupdate.DAYS_AHEAD", 14)
|
||||
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki")
|
||||
|
||||
CliRunner().list_certs(show_all=False)
|
||||
|
||||
mock_load.assert_called_once_with("/pki", 30, 14)
|
||||
assert "(no certificates)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_list_certs_all_uses_load_all_certs(monkeypatch, capsys):
|
||||
mock_load = MagicMock(return_value=[])
|
||||
monkeypatch.setattr("openvpncertupdate.load_all_certs", mock_load)
|
||||
monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki")
|
||||
|
||||
CliRunner().list_certs(show_all=True)
|
||||
|
||||
mock_load.assert_called_once_with("/pki")
|
||||
assert "(no certificates)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_list_certs_prints_formatted_table(monkeypatch, capsys):
|
||||
import openvpncertupdate
|
||||
certs = [openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=10)]
|
||||
monkeypatch.setattr("openvpncertupdate.load_expiring_certs", MagicMock(return_value=certs))
|
||||
monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="alice@example.com"))
|
||||
|
||||
CliRunner().list_certs(show_all=False)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "alice" in out
|
||||
assert "alice@example.com" in out
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `python3 -m pytest tests/test_cli.py -k list_certs -v`
|
||||
Expected: FAIL — `AttributeError: 'CliRunner' object has no attribute 'list_certs'`
|
||||
|
||||
- [ ] **Step 3: Implement `CliRunner.list_certs()`**
|
||||
|
||||
In `openvpncertupdate.py`, add this method to `class CliRunner`, directly after `def regen_crl(self) -> None:` and before `def _issue(...)`:
|
||||
|
||||
```python
|
||||
def list_certs(self, show_all: bool) -> None:
|
||||
certs = (load_all_certs(EASYRSA_PKI_DIR) if show_all
|
||||
else load_expiring_certs(EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD))
|
||||
print(_format_cert_table(certs))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `python3 -m pytest tests/test_cli.py -k list_certs -v`
|
||||
Expected: 3 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add openvpncertupdate.py tests/test_cli.py
|
||||
git commit -m "add CliRunner.list_certs()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `--list` / `--list-all` CLI flags and dispatch
|
||||
|
||||
**Files:**
|
||||
- Modify: `openvpncertupdate.py` (`_build_parser()` and `main()`)
|
||||
- Test: `tests/test_cli.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CliRunner.list_certs(show_all: bool) -> None` (Task 2), existing `resolve_ca_passphrase()`, `_build_parser()`.
|
||||
- Produces: `args.list: bool`, `args.list_all: bool` on the parsed namespace — internal to `main()`, no other task depends on these names.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_cli.py`, in the `main() argument dispatch` section, directly after `test_main_dispatches_gen_crl`:
|
||||
|
||||
```python
|
||||
def test_main_dispatches_list(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["prog", "--list"])
|
||||
runner = MagicMock()
|
||||
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
|
||||
main()
|
||||
runner.list_certs.assert_called_once_with(show_all=False)
|
||||
|
||||
|
||||
def test_main_dispatches_list_all(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["prog", "--list-all"])
|
||||
runner = MagicMock()
|
||||
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner)
|
||||
main()
|
||||
runner.list_certs.assert_called_once_with(show_all=True)
|
||||
|
||||
|
||||
def test_main_list_skips_ca_passphrase_resolution(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["prog", "--list"])
|
||||
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock())
|
||||
mock_resolve = MagicMock()
|
||||
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve)
|
||||
main()
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
|
||||
def test_main_list_all_skips_ca_passphrase_resolution(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["prog", "--list-all"])
|
||||
monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock())
|
||||
mock_resolve = MagicMock()
|
||||
monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve)
|
||||
main()
|
||||
mock_resolve.assert_not_called()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `python3 -m pytest tests/test_cli.py -k "dispatches_list or list_skips or list_all_skips" -v`
|
||||
Expected: FAIL — `error: unrecognized arguments: --list` (argparse `SystemExit`)
|
||||
|
||||
- [ ] **Step 3: Add the flags to `_build_parser()`**
|
||||
|
||||
In `openvpncertupdate.py`, find this existing line in `_build_parser()`:
|
||||
|
||||
```python
|
||||
group.add_argument("--gen-crl", action="store_true", help="Regenerate and copy CRL")
|
||||
```
|
||||
|
||||
Leave it unchanged, and insert these two new lines directly after it:
|
||||
|
||||
```python
|
||||
group.add_argument("--list", action="store_true",
|
||||
help="List recently-expired/soon-to-expire CNs "
|
||||
"(per DAYS_PAST/DAYS_AHEAD) with email")
|
||||
group.add_argument("--list-all", action="store_true", help="List all CNs with email")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Dispatch the flags in `main()`, skipping CA passphrase resolution**
|
||||
|
||||
In `openvpncertupdate.py`, `main()` currently reads:
|
||||
|
||||
```python
|
||||
def main() -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
overrides = load_settings_overrides(args.config, CONFIG_PATH, __file__)
|
||||
except ConfigError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
globals().update(overrides)
|
||||
|
||||
global CA_PASSPHRASE
|
||||
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)
|
||||
|
||||
# --show-eml switches the default to --no-send-email; explicit --send-email overrides.
|
||||
send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml)
|
||||
|
||||
if args.create:
|
||||
```
|
||||
|
||||
Replace the block from `global CA_PASSPHRASE` up to (not including) `if args.create:` with:
|
||||
|
||||
```python
|
||||
if args.list:
|
||||
CliRunner().list_certs(show_all=False)
|
||||
return
|
||||
if args.list_all:
|
||||
CliRunner().list_certs(show_all=True)
|
||||
return
|
||||
|
||||
global CA_PASSPHRASE
|
||||
CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR)
|
||||
|
||||
# --show-eml switches the default to --no-send-email; explicit --send-email overrides.
|
||||
send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml)
|
||||
|
||||
if args.create:
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `python3 -m pytest tests/test_cli.py -v`
|
||||
Expected: all tests in the file PASS (this also re-verifies Tasks 1–2 didn't regress, and that the pre-existing `test_main_resolves_ca_passphrase_before_dispatch` / `test_main_dispatches_gen_crl` etc. still pass since `--gen-crl` and friends are untouched)
|
||||
|
||||
- [ ] **Step 6: Run the full suite**
|
||||
|
||||
Run: `python3 -m pytest tests/ -v`
|
||||
Expected: all tests PASS, no regressions in other files
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add openvpncertupdate.py tests/test_cli.py
|
||||
git commit -m "add --list/--list-all CLI flags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update CLAUDE.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (documentation only).
|
||||
- Produces: nothing (documentation only).
|
||||
|
||||
- [ ] **Step 1: Add example invocations**
|
||||
|
||||
In `CLAUDE.md`, in the `## Running` fenced code block, add two lines after `python3 openvpncertupdate.py --gen-crl`:
|
||||
|
||||
```
|
||||
python3 openvpncertupdate.py --list
|
||||
python3 openvpncertupdate.py --list-all
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the flags to the CLI flags table**
|
||||
|
||||
In `CLAUDE.md`, in the "### CLI flags" table, add two rows directly after the `--gen-crl` row:
|
||||
|
||||
```
|
||||
| `--gen-crl` | Regenerate and copy CRL only |
|
||||
| `--list` | List recently-expired/soon-to-expire CNs (per `DAYS_PAST`/`DAYS_AHEAD`) with email; read-only, no CA passphrase needed |
|
||||
| `--list-all` | List all CNs with email; read-only, no CA passphrase needed |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Note the CA-passphrase exception in Key constraints**
|
||||
|
||||
In `CLAUDE.md`, in `## Key constraints`, the line starting `- CA passphrase resolution (...)` currently ends with `any other value → used literally`. Append a new sentence to that same bullet:
|
||||
|
||||
```
|
||||
`--list`/`--list-all` skip this resolution entirely since they only read `index.txt` and never touch the CA
|
||||
```
|
||||
|
||||
So the full bullet reads:
|
||||
|
||||
```
|
||||
- CA passphrase resolution (`resolve_ca_passphrase()`, run once in `main()` right after arg parsing, before dispatch): `CA_PASSPHRASE=""` → auto-detect via `is_ca_key_encrypted()` (checks `<PKI_DIR>/private/ca.key` PEM header for `ENCRYPTED`) and prompt only if encrypted; `"!empty"` → never check/prompt, passphrase is `""`; `"!ask"` → always prompt, skip detection; any other value → used literally. `--list`/`--list-all` skip this resolution entirely since they only read `index.txt` and never touch the CA
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify no stale claims remain**
|
||||
|
||||
Run: `grep -n "openvpncertupdate-metadata" CLAUDE.md`
|
||||
Expected: no output (confirms Task 4 didn't reintroduce the removed metadata-file reference)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "document --list/--list-all in CLAUDE.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] Run `python3 -m pytest tests/ -v` — all tests pass, count should be 126 (prior) + 11 new = 137.
|
||||
- [ ] Manually sanity-check the two new flags against the real `tests/` fixtures aren't needed (no live PKI in this repo) — rely on the unit tests above; if a live PKI directory is available, run `python3 openvpncertupdate.py --list` and `--list-all` by hand to eyeball alignment.
|
||||
135
docs/superpowers/plans/2026-07-08-tui-list-emails.md
Normal file
135
docs/superpowers/plans/2026-07-08-tui-list-emails.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# TUI Main Menu Email Column Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show each cert's email address alongside its CN in the TUI main menu's cert list rows.
|
||||
|
||||
**Architecture:** `CertInfo.email` is already populated by `load_expiring_certs()`/`load_all_certs()` from `index.txt` (`_parse_index_line()`), and `show_main_screen()` already receives the full `certs: list[CertInfo]`. This is a pure rendering change: extend the existing row-format string (`openvpncertupdate.py`, inside `show_main_screen()`'s render loop) to append the email, padding the CN column to a fixed width so columns stay aligned. No new data plumbing.
|
||||
|
||||
**Tech Stack:** Python 3.9 stdlib `curses`. Tests via `pytest` + `unittest.mock`, matching `tests/test_main_screen.py`'s existing conventions (mocked `stdscr`, inspecting `stdscr.addstr.call_args_list`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Single-file script: change stays in `openvpncertupdate.py`, no new modules.
|
||||
- Applies to both the expiring-only and "all certs" (`A`-toggle) views — they share this same render loop, so no separate handling needed.
|
||||
- Unknown email renders as blank (not a placeholder string like `(none)`) — matches the TUI's existing minimalist row style; this is a deliberate difference from the `--list`/`--list-all` CLI table, which does use `(none)`.
|
||||
- CN column gets a fixed width of 20 characters (padding with spaces), consistent with the existing fixed 18-char width already used for the expiry label column on the same line.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify `openvpncertupdate.py`:** one line inside `show_main_screen()`'s render loop (currently `line = f" {box} {lbl:<18} {cert.cn}"`, around line 941 — find it via `_expiry_label(cert)` immediately above it).
|
||||
- **Modify `tests/test_main_screen.py`:** extend the `_cert()` test helper to accept an optional `email` argument, and add two new tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add email column to the cert list row
|
||||
|
||||
**Files:**
|
||||
- Modify: `openvpncertupdate.py` (inside `show_main_screen()`, the `if is_cert(i):` branch of the render loop)
|
||||
- Modify: `tests/test_main_screen.py` (`_cert()` helper + new tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CertInfo.email: str` (already exists, default `""`), `CertInfo.cn: str`.
|
||||
- Produces: nothing new — this is a leaf rendering change with no other task depending on it.
|
||||
|
||||
- [ ] **Step 1: Extend the `_cert()` test helper**
|
||||
|
||||
In `tests/test_main_screen.py`, replace:
|
||||
|
||||
```python
|
||||
def _cert(cn: str, days_left: int) -> CertInfo:
|
||||
"""Build a minimal CertInfo; expires value is a plausible UTC datetime."""
|
||||
return CertInfo(
|
||||
cn=cn,
|
||||
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
|
||||
days_left=days_left,
|
||||
)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
def _cert(cn: str, days_left: int, email: str = "") -> CertInfo:
|
||||
"""Build a minimal CertInfo; expires value is a plausible UTC datetime."""
|
||||
return CertInfo(
|
||||
cn=cn,
|
||||
expires=datetime(2030, 1, 1, tzinfo=timezone.utc),
|
||||
days_left=days_left,
|
||||
email=email,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
In `tests/test_main_screen.py`, add directly after `test_cursor_row_has_reverse_attribute` (search for that function name):
|
||||
|
||||
```python
|
||||
def test_cert_row_shows_email():
|
||||
certs = [_cert("alice", 10, email="alice@example.com"), _cert("bob", 3)]
|
||||
stdscr = _make_stdscr()
|
||||
stdscr.getch.side_effect = [ord("q")]
|
||||
with patch("openvpncertupdate.init_colors"):
|
||||
show_main_screen(stdscr, certs)
|
||||
alice_calls = [c for c in stdscr.addstr.call_args_list if "alice" in str(c.args[2])]
|
||||
assert alice_calls
|
||||
assert "alice@example.com" in alice_calls[0].args[2]
|
||||
|
||||
|
||||
def test_cert_row_blank_when_no_email():
|
||||
certs = [_cert("bob", 3)] # email defaults to ""
|
||||
stdscr = _make_stdscr()
|
||||
stdscr.getch.side_effect = [ord("q")]
|
||||
with patch("openvpncertupdate.init_colors"):
|
||||
show_main_screen(stdscr, certs)
|
||||
bob_calls = [c for c in stdscr.addstr.call_args_list if "bob" in str(c.args[2])]
|
||||
assert bob_calls
|
||||
assert "(none)" not in bob_calls[0].args[2]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to verify they fail**
|
||||
|
||||
Run: `python3 -m pytest tests/test_main_screen.py -k cert_row_shows_email -v`
|
||||
Expected: FAIL — `assert "alice@example.com" in alice_calls[0].args[2]` fails because the row doesn't include the email yet (`test_cert_row_blank_when_no_email` will already pass since it only checks for absence of `"(none)"`, which is fine — it's asserting current behavior stays true after the change too, so it's not required to fail here, but run both to confirm the shows_email one fails)
|
||||
|
||||
- [ ] **Step 4: Update the row format**
|
||||
|
||||
In `openvpncertupdate.py`, inside `show_main_screen()`, find:
|
||||
|
||||
```python
|
||||
if is_cert(i):
|
||||
cert = certs[i]
|
||||
box = "[X]" if cert.cn in checked else "[ ]"
|
||||
lbl = _expiry_label(cert)
|
||||
line = f" {box} {lbl:<18} {cert.cn}"
|
||||
```
|
||||
|
||||
Replace the `line = ...` assignment with:
|
||||
|
||||
```python
|
||||
line = f" {box} {lbl:<18} {cert.cn:<20} {cert.email}"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `python3 -m pytest tests/test_main_screen.py -v`
|
||||
Expected: all tests in the file PASS (this also re-verifies no regressions in the pre-existing tests, since the row content assertions there — e.g. `test_cursor_row_has_reverse_attribute` — check for `"bob"`/`"alice"` substrings, not exact row content, so the added columns don't break them)
|
||||
|
||||
- [ ] **Step 6: Run the full suite**
|
||||
|
||||
Run: `python3 -m pytest tests/ -v`
|
||||
Expected: all tests PASS, no regressions in other files
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add openvpncertupdate.py tests/test_main_screen.py
|
||||
git commit -m "show email alongside CN in TUI main menu cert list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] Run `python3 -m pytest tests/ -v` — all tests pass, count should be 141 (prior) + 2 new = 143.
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""openvpncertupdate — Manage OpenVPN user certificates via EasyRSA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import curses
|
||||
@@ -21,13 +19,19 @@ import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
# cryptography <41 pinned for Python 3.6 warns on every import that 3.6
|
||||
# support is deprecated; the pin keeps us on a supported release, so the
|
||||
# warning is noise that would otherwise pollute CLI/cron stderr each run.
|
||||
warnings.filterwarnings("ignore", message="Python 3.6 is no longer supported")
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
except ImportError: # pragma: no cover
|
||||
AESGCM = None # type: ignore
|
||||
@@ -144,7 +148,7 @@ def _parse_date(raw: str) -> datetime:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _parse_index_line(line: str) -> Optional[tuple[str, datetime, str]]:
|
||||
def _parse_index_line(line: str) -> Optional[Tuple[str, datetime, str]]:
|
||||
"""Return (cn, expiry, email) for valid (V-status) index.txt lines, else None."""
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) < 6 or parts[0] != "V":
|
||||
@@ -166,7 +170,7 @@ def _parse_index_line(line: str) -> Optional[tuple[str, datetime, str]]:
|
||||
return cn, expiry, email
|
||||
|
||||
|
||||
def _load_current_certs(pki_dir: str) -> list[CertInfo]:
|
||||
def _load_current_certs(pki_dir: str) -> List[CertInfo]:
|
||||
"""Return one CertInfo per CN: its most recent V-status index.txt entry.
|
||||
|
||||
index.txt is append-only, so a CN can accumulate several V-status lines
|
||||
@@ -177,7 +181,7 @@ def _load_current_certs(pki_dir: str) -> list[CertInfo]:
|
||||
so expiring/all-certs views don't show stale duplicate rows.
|
||||
"""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
by_cn: dict[str, CertInfo] = {}
|
||||
by_cn: Dict[str, CertInfo] = {}
|
||||
with open(os.path.join(pki_dir, "index.txt")) as fh:
|
||||
for line in fh:
|
||||
parsed = _parse_index_line(line)
|
||||
@@ -197,7 +201,7 @@ def load_expiring_certs(
|
||||
pki_dir: str,
|
||||
days_past: int,
|
||||
days_ahead: int,
|
||||
) -> list[CertInfo]:
|
||||
) -> List[CertInfo]:
|
||||
"""Return current certs whose expiry falls within [-days_past, +days_ahead]."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
cutoff_past = now - timedelta(days=days_past)
|
||||
@@ -208,7 +212,7 @@ def load_expiring_certs(
|
||||
return results
|
||||
|
||||
|
||||
def load_all_certs(pki_dir: str) -> list[CertInfo]:
|
||||
def load_all_certs(pki_dir: str) -> List[CertInfo]:
|
||||
"""Return all current certs, sorted by expiry date."""
|
||||
results = _load_current_certs(pki_dir)
|
||||
results.sort(key=lambda c: c.expires)
|
||||
@@ -257,13 +261,16 @@ class EasyRSAError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _run_easyrsa(cmd: list[str], cwd: str,
|
||||
extra_env: Optional[dict[str, str]] = None) -> None:
|
||||
def _run_easyrsa(cmd: List[str], cwd: str,
|
||||
extra_env: Optional[Dict[str, str]] = None) -> None:
|
||||
env = None
|
||||
if extra_env:
|
||||
env = os.environ.copy()
|
||||
env.update(extra_env)
|
||||
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env)
|
||||
result = subprocess.run(
|
||||
cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Find the first non-flag argument after the binary (the actual easyrsa verb)
|
||||
verb = next((c for c in cmd[1:] if not c.startswith("-")), "unknown")
|
||||
@@ -272,7 +279,7 @@ def _run_easyrsa(cmd: list[str], cwd: str,
|
||||
)
|
||||
|
||||
|
||||
def _base_cmd(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> list[str]:
|
||||
def _base_cmd(easyrsa_dir: str, pki_dir: str, ca_passphrase: str) -> List[str]:
|
||||
cmd = [f"{easyrsa_dir}/easyrsa", "--batch", f"--pki={pki_dir}"]
|
||||
if ca_passphrase:
|
||||
cmd.append(f"--passin=pass:{ca_passphrase}")
|
||||
@@ -315,7 +322,11 @@ def copy_crl(pki_dir: str, dest_path: str, restorecon_binary: str = "") -> None:
|
||||
# Best-effort, like is_ca_key_encrypted(): a missing/misconfigured
|
||||
# restorecon must not break CRL deployment on non-SELinux systems.
|
||||
try:
|
||||
subprocess.run([restorecon_binary, dest_path], capture_output=True, text=True)
|
||||
subprocess.run(
|
||||
[restorecon_binary, dest_path],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -727,7 +738,7 @@ def show_cert_form(
|
||||
|
||||
active = ([n for n in _FORM_FIELDS if not (n == "cn" and cn_readonly)]
|
||||
+ [_BTN_CONTINUE, _BTN_CANCEL])
|
||||
fields: dict[str, InputField] = {
|
||||
fields: Dict[str, InputField] = {
|
||||
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
|
||||
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
|
||||
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
|
||||
@@ -857,8 +868,8 @@ class Action(Enum):
|
||||
@dataclass
|
||||
class ScreenResult:
|
||||
action: Action
|
||||
selected_cns: list[str] = field(default_factory=list)
|
||||
saved_checked: list[str] = field(default_factory=list)
|
||||
selected_cns: List[str] = field(default_factory=list)
|
||||
saved_checked: List[str] = field(default_factory=list)
|
||||
saved_cursor: int = 0
|
||||
|
||||
|
||||
@@ -877,8 +888,8 @@ def _expiry_label(c: CertInfo) -> str:
|
||||
|
||||
|
||||
def show_main_screen(
|
||||
stdscr, certs: list[CertInfo], show_all: bool = False,
|
||||
initial_checked: Optional[set[str]] = None,
|
||||
stdscr, certs: List[CertInfo], show_all: bool = False,
|
||||
initial_checked: Optional[Set[str]] = None,
|
||||
initial_cursor: int = 0,
|
||||
) -> ScreenResult:
|
||||
"""Display cert selection list. Returns when user activates an action."""
|
||||
@@ -887,7 +898,7 @@ def show_main_screen(
|
||||
|
||||
n_certs = len(certs)
|
||||
n_items = n_certs + len(_MENU_ITEMS)
|
||||
checked: set[str] = set(initial_checked) if initial_checked else set()
|
||||
checked: Set[str] = set(initial_checked) if initial_checked else set()
|
||||
cursor = clamp(initial_cursor, 0, max(0, n_items - 1))
|
||||
esc_pending = False
|
||||
|
||||
@@ -1037,7 +1048,7 @@ def show_main_screen(
|
||||
|
||||
class CursesApp:
|
||||
def __init__(self) -> None:
|
||||
self._session_log: list[str] = []
|
||||
self._session_log: List[str] = []
|
||||
|
||||
def _log(self, entry: str) -> None:
|
||||
self._session_log.append(entry)
|
||||
@@ -1062,7 +1073,7 @@ class CursesApp:
|
||||
init_colors()
|
||||
|
||||
show_all = False
|
||||
saved_checked: list[str] = []
|
||||
saved_checked: List[str] = []
|
||||
saved_cursor: int = 0
|
||||
|
||||
while True:
|
||||
@@ -1297,7 +1308,7 @@ class CursesApp:
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _format_cert_table(certs: list[CertInfo]) -> str:
|
||||
def _format_cert_table(certs: List[CertInfo]) -> str:
|
||||
"""Render CN / expiry / email as an aligned table, like `ls -l`."""
|
||||
if not certs:
|
||||
return "(no certificates)"
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
cryptography>=41.0.0
|
||||
cryptography>=41.0.0; python_version >= "3.7"
|
||||
cryptography>=3.4,<41; python_version < "3.7"
|
||||
dataclasses>=0.8; python_version < "3.7"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from openvpncertupdate import (
|
||||
@@ -82,7 +84,9 @@ def test_copy_crl_skips_restorecon_when_binary_empty(mock_chmod, mock_copy, mock
|
||||
def test_copy_crl_runs_restorecon_when_binary_set(mock_chmod, mock_copy, mock_run):
|
||||
copy_crl("/pki", "/etc/openvpn/crl.pem", restorecon_binary="restorecon")
|
||||
mock_run.assert_called_once_with(
|
||||
["restorecon", "/etc/openvpn/crl.pem"], capture_output=True, text=True)
|
||||
["restorecon", "/etc/openvpn/crl.pem"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True)
|
||||
|
||||
|
||||
@patch("openvpncertupdate.subprocess.run", side_effect=FileNotFoundError("no restorecon"))
|
||||
|
||||
Reference in New Issue
Block a user