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

18 KiB
Raw Permalink Blame History

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:

# ---------------------------------------------------------------------------
# _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 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:

# ---------------------------------------------------------------------------
# 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() 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:

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 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
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, run python3 openvpncertupdate.py --list and --list-all by hand to eyeball alignment.