Track the superpowers planning documents under docs/superpowers/plans/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
18 KiB
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
CliRunnerconventions: business logic prints viaprint()/sys.stderr, no return values needed by callers. DAYS_PAST/DAYS_AHEAD(SETTINGS constants, currently 30 / 14) define the--listwindow — 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(fromget_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-allmust be mutually exclusive with each other and with--create/--reissue/--revoke/--gen-crl(sameargparsegroup), and must NOT triggerresolve_ca_passphrase().
File Structure
- Modify
openvpncertupdate.py:- Add
_format_cert_table()— new function, CLI section, directly aboveclass CliRunner:. - Add
CliRunner.list_certs()— new method on the existing class. - Modify
_build_parser()— add--list/--list-allto 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.
- Add
- 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.argvdispatch 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 aboveclass 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 globalEASYRSA_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:
# ---------------------------------------------------------------------------
# _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:):
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
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 toclass CliRunner, currently spanning fromdef create(...)throughdef _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 globalsEASYRSA_PKI_DIR,DAYS_PAST,DAYS_AHEAD. -
Produces:
CliRunner.list_certs(self, show_all: bool) -> None— used by Task 3'smain()dispatch. -
Step 1: Write the failing tests
Add to tests/test_cli.py, directly after the _format_cert_table tests from Task 1:
# ---------------------------------------------------------------------------
# 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(...):
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
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()andmain()) - Test:
tests/test_cli.py
Interfaces:
-
Consumes:
CliRunner.list_certs(show_all: bool) -> None(Task 2), existingresolve_ca_passphrase(),_build_parser(). -
Produces:
args.list: bool,args.list_all: boolon the parsed namespace — internal tomain(), 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:
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():
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:
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:
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:
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
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
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, runpython3 openvpncertupdate.py --listand--list-allby hand to eyeball alignment.