Files
openvpncertupdate/docs/superpowers/plans/2026-07-08-cli-list-certs.md
Vlad Doloman 6c60a63eb2 Add planning docs
Track the superpowers planning documents under docs/superpowers/plans/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:56:29 +03:00

426 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 12 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.