devnomads-cli 0.7.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/PKG-INFO +18 -12
  2. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/README.md +16 -10
  3. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/devnomads_cli.egg-info/PKG-INFO +18 -12
  4. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/devnomads_cli.egg-info/requires.txt +1 -1
  5. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/dncli.py +102 -7
  6. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/pyproject.toml +2 -2
  7. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_cert.py +141 -33
  8. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/LICENSE +0 -0
  9. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  10. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  11. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  12. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/setup.cfg +0 -0
  14. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_cli.py +0 -0
  15. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.7.0 → devnomads_cli-0.9.0}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -11,7 +11,7 @@ Requires-Dist: typer>=0.12
11
11
  Requires-Dist: httpx>=0.27
12
12
  Requires-Dist: rich>=13
13
13
  Requires-Dist: cryptography>=42
14
- Requires-Dist: devnomads[acme]>=0.2.5
14
+ Requires-Dist: devnomads[acme]>=0.5.0
15
15
  Dynamic: license-file
16
16
 
17
17
  # dncli
@@ -148,11 +148,13 @@ dncli services list | jq -r '.[].entity'
148
148
  `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
149
149
 
150
150
  ```sh
151
- dncli cert issue example.com -d www.example.com -d "*.example.com"
151
+ dncli certs issue example.com -d www.example.com --wildcard
152
152
  ```
153
153
 
154
154
  The first argument is the primary domain (the certificate CN); add
155
- extra names (SANs) by repeating `--san`/`-d`. Every name must live in
155
+ extra names (SANs) by repeating `--san`/`-d`. `--wildcard`/`-w` is a
156
+ shortcut that adds `*.<domain>` as a SAN - a wildcard is never
157
+ accepted as the CN itself. Every name must live in
156
158
  one of your DevNomads DNS zones, and that zone must be delegated to the
157
159
  DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
158
160
  `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
@@ -176,37 +178,41 @@ and `--eab-hmac-key` or the `DN_ZEROSSL_EAB_KID` and
176
178
  `DN_ZEROSSL_EAB_HMAC_KEY` environment variables:
177
179
 
178
180
  ```sh
179
- dncli cert issue example.com --provider zerossl \
181
+ dncli certs issue example.com --provider zerossl \
180
182
  --eab-kid <kid> --eab-hmac-key <hmac>
181
183
  ```
182
184
 
183
185
  `--staging` (the Let's Encrypt staging CA) applies only to the
184
- letsencrypt provider. `cert renew` takes the same `--provider` and EAB
186
+ letsencrypt provider. `certs renew` takes the same `--provider` and EAB
185
187
  options.
186
188
 
187
189
  Each CA gets its own ACME account: the key lives in
188
190
  `~/.config/dncli/acme/` as `account.pem` for Let's Encrypt production
189
191
  and `account-<provider>.pem` for ZeroSSL and the staging CA.
190
192
 
191
- Re-running `cert issue` is a no-op while the existing certificate is
193
+ Re-running `certs issue` is a no-op while the existing certificate is
192
194
  still valid for more than 21 days; pass `--force` to re-issue anyway.
193
195
 
194
196
  List what you have issued and re-export any of them - as a PEM bundle
195
- or a PKCS#12 (`.pfx`) file - without re-issuing. `cert list` includes a
197
+ or a PKCS#12 (`.pfx`) file - without re-issuing. `certs list` includes a
196
198
  `provider` column showing the issuing CA (recorded at issue time, or
197
199
  inferred from the certificate's issuer for older certificates):
198
200
 
199
201
  ```sh
200
- dncli cert list
201
- dncli cert export example.com --out bundle.pem # omit --out to print
202
- dncli cert export example.com --format pfx --out bundle.pfx
203
- dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
202
+ dncli certs list
203
+ dncli certs export example.com --out bundle.pem # omit --out to print
204
+ dncli certs export example.com --format pfx --out bundle.pfx
205
+ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
204
206
  ```
205
207
 
206
208
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
207
209
  `--password`). Both bundle formats carry the same three items: key,
208
210
  certificate, and the issuing intermediate.
209
211
 
212
+ `dncli certs delete <domain>` removes a stored certificate and its
213
+ private key from disk (after confirmation; `--yes` skips it). It only
214
+ deletes the local files - the certificate itself is not revoked.
215
+
210
216
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
211
217
 
212
218
  ```sh
@@ -132,11 +132,13 @@ dncli services list | jq -r '.[].entity'
132
132
  `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
133
133
 
134
134
  ```sh
135
- dncli cert issue example.com -d www.example.com -d "*.example.com"
135
+ dncli certs issue example.com -d www.example.com --wildcard
136
136
  ```
137
137
 
138
138
  The first argument is the primary domain (the certificate CN); add
139
- extra names (SANs) by repeating `--san`/`-d`. Every name must live in
139
+ extra names (SANs) by repeating `--san`/`-d`. `--wildcard`/`-w` is a
140
+ shortcut that adds `*.<domain>` as a SAN - a wildcard is never
141
+ accepted as the CN itself. Every name must live in
140
142
  one of your DevNomads DNS zones, and that zone must be delegated to the
141
143
  DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
142
144
  `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
@@ -160,37 +162,41 @@ and `--eab-hmac-key` or the `DN_ZEROSSL_EAB_KID` and
160
162
  `DN_ZEROSSL_EAB_HMAC_KEY` environment variables:
161
163
 
162
164
  ```sh
163
- dncli cert issue example.com --provider zerossl \
165
+ dncli certs issue example.com --provider zerossl \
164
166
  --eab-kid <kid> --eab-hmac-key <hmac>
165
167
  ```
166
168
 
167
169
  `--staging` (the Let's Encrypt staging CA) applies only to the
168
- letsencrypt provider. `cert renew` takes the same `--provider` and EAB
170
+ letsencrypt provider. `certs renew` takes the same `--provider` and EAB
169
171
  options.
170
172
 
171
173
  Each CA gets its own ACME account: the key lives in
172
174
  `~/.config/dncli/acme/` as `account.pem` for Let's Encrypt production
173
175
  and `account-<provider>.pem` for ZeroSSL and the staging CA.
174
176
 
175
- Re-running `cert issue` is a no-op while the existing certificate is
177
+ Re-running `certs issue` is a no-op while the existing certificate is
176
178
  still valid for more than 21 days; pass `--force` to re-issue anyway.
177
179
 
178
180
  List what you have issued and re-export any of them - as a PEM bundle
179
- or a PKCS#12 (`.pfx`) file - without re-issuing. `cert list` includes a
181
+ or a PKCS#12 (`.pfx`) file - without re-issuing. `certs list` includes a
180
182
  `provider` column showing the issuing CA (recorded at issue time, or
181
183
  inferred from the certificate's issuer for older certificates):
182
184
 
183
185
  ```sh
184
- dncli cert list
185
- dncli cert export example.com --out bundle.pem # omit --out to print
186
- dncli cert export example.com --format pfx --out bundle.pfx
187
- dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
186
+ dncli certs list
187
+ dncli certs export example.com --out bundle.pem # omit --out to print
188
+ dncli certs export example.com --format pfx --out bundle.pfx
189
+ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
188
190
  ```
189
191
 
190
192
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
191
193
  `--password`). Both bundle formats carry the same three items: key,
192
194
  certificate, and the issuing intermediate.
193
195
 
196
+ `dncli certs delete <domain>` removes a stored certificate and its
197
+ private key from disk (after confirmation; `--yes` skips it). It only
198
+ deletes the local files - the certificate itself is not revoked.
199
+
194
200
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
195
201
 
196
202
  ```sh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -11,7 +11,7 @@ Requires-Dist: typer>=0.12
11
11
  Requires-Dist: httpx>=0.27
12
12
  Requires-Dist: rich>=13
13
13
  Requires-Dist: cryptography>=42
14
- Requires-Dist: devnomads[acme]>=0.2.5
14
+ Requires-Dist: devnomads[acme]>=0.5.0
15
15
  Dynamic: license-file
16
16
 
17
17
  # dncli
@@ -148,11 +148,13 @@ dncli services list | jq -r '.[].entity'
148
148
  `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
149
149
 
150
150
  ```sh
151
- dncli cert issue example.com -d www.example.com -d "*.example.com"
151
+ dncli certs issue example.com -d www.example.com --wildcard
152
152
  ```
153
153
 
154
154
  The first argument is the primary domain (the certificate CN); add
155
- extra names (SANs) by repeating `--san`/`-d`. Every name must live in
155
+ extra names (SANs) by repeating `--san`/`-d`. `--wildcard`/`-w` is a
156
+ shortcut that adds `*.<domain>` as a SAN - a wildcard is never
157
+ accepted as the CN itself. Every name must live in
156
158
  one of your DevNomads DNS zones, and that zone must be delegated to the
157
159
  DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
158
160
  `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
@@ -176,37 +178,41 @@ and `--eab-hmac-key` or the `DN_ZEROSSL_EAB_KID` and
176
178
  `DN_ZEROSSL_EAB_HMAC_KEY` environment variables:
177
179
 
178
180
  ```sh
179
- dncli cert issue example.com --provider zerossl \
181
+ dncli certs issue example.com --provider zerossl \
180
182
  --eab-kid <kid> --eab-hmac-key <hmac>
181
183
  ```
182
184
 
183
185
  `--staging` (the Let's Encrypt staging CA) applies only to the
184
- letsencrypt provider. `cert renew` takes the same `--provider` and EAB
186
+ letsencrypt provider. `certs renew` takes the same `--provider` and EAB
185
187
  options.
186
188
 
187
189
  Each CA gets its own ACME account: the key lives in
188
190
  `~/.config/dncli/acme/` as `account.pem` for Let's Encrypt production
189
191
  and `account-<provider>.pem` for ZeroSSL and the staging CA.
190
192
 
191
- Re-running `cert issue` is a no-op while the existing certificate is
193
+ Re-running `certs issue` is a no-op while the existing certificate is
192
194
  still valid for more than 21 days; pass `--force` to re-issue anyway.
193
195
 
194
196
  List what you have issued and re-export any of them - as a PEM bundle
195
- or a PKCS#12 (`.pfx`) file - without re-issuing. `cert list` includes a
197
+ or a PKCS#12 (`.pfx`) file - without re-issuing. `certs list` includes a
196
198
  `provider` column showing the issuing CA (recorded at issue time, or
197
199
  inferred from the certificate's issuer for older certificates):
198
200
 
199
201
  ```sh
200
- dncli cert list
201
- dncli cert export example.com --out bundle.pem # omit --out to print
202
- dncli cert export example.com --format pfx --out bundle.pfx
203
- dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
202
+ dncli certs list
203
+ dncli certs export example.com --out bundle.pem # omit --out to print
204
+ dncli certs export example.com --format pfx --out bundle.pfx
205
+ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
204
206
  ```
205
207
 
206
208
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
207
209
  `--password`). Both bundle formats carry the same three items: key,
208
210
  certificate, and the issuing intermediate.
209
211
 
212
+ `dncli certs delete <domain>` removes a stored certificate and its
213
+ private key from disk (after confirmation; `--yes` skips it). It only
214
+ deletes the local files - the certificate itself is not revoked.
215
+
210
216
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
211
217
 
212
218
  ```sh
@@ -2,4 +2,4 @@ typer>=0.12
2
2
  httpx>=0.27
3
3
  rich>=13
4
4
  cryptography>=42
5
- devnomads[acme]>=0.2.5
5
+ devnomads[acme]>=0.5.0
@@ -29,6 +29,7 @@ import logging
29
29
  import os
30
30
  import re
31
31
  import secrets
32
+ import shutil
32
33
  import stat
33
34
  import sys
34
35
  import time
@@ -52,7 +53,7 @@ from rich.markup import escape
52
53
  from rich.table import Table
53
54
  from typer.core import TyperGroup
54
55
 
55
- __version__ = "0.7.0"
56
+ __version__ = "0.9.0"
56
57
 
57
58
  # Distribution names, for reporting versions via `dncli version`.
58
59
  CLI_PACKAGE = "devnomads-cli"
@@ -1725,12 +1726,14 @@ def dns_hook(
1725
1726
 
1726
1727
 
1727
1728
  # ---------------------------------------------------------------------------
1728
- # Certificate issuance (`dncli cert ...`), behind the `cert` extra.
1729
+ # Certificate issuance (`dncli certs ...`), behind the `cert` extra.
1729
1730
 
1730
1731
  cert_app = typer.Typer(
1731
1732
  cls=PrefixGroup, help="Issue and renew TLS certificates.", no_args_is_help=True
1732
1733
  )
1733
- app.add_typer(cert_app, name="cert")
1734
+ # the singular `dncli cert ...` keeps working (hidden from help) because
1735
+ # PrefixGroup resolves it as a unique prefix of `certs`
1736
+ app.add_typer(cert_app, name="certs")
1734
1737
 
1735
1738
  CERT_RENEW_WINDOW_DAYS = 30
1736
1739
  # `cert issue` skips a domain whose current certificate is still valid for
@@ -1785,6 +1788,18 @@ class KeyType(str, Enum):
1785
1788
  rsa4096 = "rsa4096"
1786
1789
 
1787
1790
 
1791
+ class PreferredChain(str, Enum):
1792
+ """Terminating root to pin the served certificate chain to (Let's Encrypt).
1793
+
1794
+ This only selects which offered chain is served, not the certificate
1795
+ itself. `ISRG Root X1` gives the broadest compatibility with older clients;
1796
+ the ECDSA-only `ISRG Root X2` chain is smaller but needs a post-2022 trust
1797
+ store. Left unset, the CA's default chain is used unchanged."""
1798
+
1799
+ isrg_root_x1 = "ISRG Root X1"
1800
+ isrg_root_x2 = "ISRG Root X2"
1801
+
1802
+
1788
1803
  class ExportFormat(str, Enum):
1789
1804
  """Bundle formats for `cert export`."""
1790
1805
 
@@ -1799,6 +1814,18 @@ ProviderOption = Annotated[
1799
1814
  help="ACME certificate authority: letsencrypt (default) or zerossl.",
1800
1815
  ),
1801
1816
  ]
1817
+ PreferredChainOption = Annotated[
1818
+ PreferredChain | None,
1819
+ typer.Option(
1820
+ "--preferred-chain",
1821
+ help=(
1822
+ "Pin the served chain to this terminating root (Let's Encrypt): "
1823
+ "'ISRG Root X1' for the broadest old-client compatibility, or "
1824
+ "'ISRG Root X2' for the smaller ECDSA-only chain. Unset keeps the "
1825
+ "CA default."
1826
+ ),
1827
+ ),
1828
+ ]
1802
1829
  EabKidOption = Annotated[
1803
1830
  str | None,
1804
1831
  typer.Option(
@@ -2022,6 +2049,22 @@ def _cert_provider(cert_path: Path, cert: Any) -> str:
2022
2049
  return _provider_from_cert(cert)
2023
2050
 
2024
2051
 
2052
+ def _cert_sans(cert_path: Path) -> list[str]:
2053
+ """DNS SANs of the stored leaf at ``cert_path`` ([] if unreadable)."""
2054
+
2055
+ from cryptography import x509
2056
+
2057
+ try:
2058
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
2059
+ return list(
2060
+ cert.extensions.get_extension_for_class(
2061
+ x509.SubjectAlternativeName
2062
+ ).value.get_values_for_type(x509.DNSName)
2063
+ )
2064
+ except (OSError, ValueError, x509.ExtensionNotFound):
2065
+ return []
2066
+
2067
+
2025
2068
  def _cert_info(cert_path: Path) -> dict[str, Any]:
2026
2069
  """Summarize a stored leaf certificate for `cert list`."""
2027
2070
 
@@ -2069,6 +2112,7 @@ def _issue_certificate(
2069
2112
  provider: str = CertProvider.letsencrypt.value,
2070
2113
  eab_kid: str | None = None,
2071
2114
  eab_hmac_key: str | None = None,
2115
+ preferred_chain: str | None = None,
2072
2116
  out_dir: Path,
2073
2117
  export_path: Path | None = None,
2074
2118
  verbose: bool = False,
@@ -2076,6 +2120,13 @@ def _issue_certificate(
2076
2120
  """Obtain a DNS-01 certificate for ``domain`` and write it to ``out_dir``;
2077
2121
  optionally also export a single combined PEM to ``export_path``."""
2078
2122
 
2123
+ if domain.startswith("*."):
2124
+ base = domain[2:]
2125
+ raise CliError(
2126
+ f"a wildcard cannot be the certificate common name; issue for the "
2127
+ f"base domain with the wildcard as a SAN: "
2128
+ f"dncli certs issue {base} --wildcard"
2129
+ )
2079
2130
  acme = _load_acme()
2080
2131
  _enable_progress_logging(verbose)
2081
2132
 
@@ -2085,6 +2136,8 @@ def _issue_certificate(
2085
2136
 
2086
2137
  provider_name = provider
2087
2138
  client_kwargs: dict[str, Any] = {"contact_email": email}
2139
+ if preferred_chain:
2140
+ client_kwargs["preferred_chain"] = preferred_chain
2088
2141
  if staging:
2089
2142
  if provider != CertProvider.letsencrypt.value:
2090
2143
  raise CliError("--staging only applies to the letsencrypt provider")
@@ -2150,11 +2203,24 @@ def _issue_certificate(
2150
2203
  @cert_app.command("issue")
2151
2204
  def cert_issue(
2152
2205
  ctx: typer.Context,
2153
- domain: Annotated[str, typer.Argument(help="Primary domain (certificate CN).")],
2206
+ domain: Annotated[
2207
+ str,
2208
+ typer.Argument(
2209
+ help="Primary domain (certificate CN); use --wildcard for *.<domain>."
2210
+ ),
2211
+ ],
2154
2212
  san: Annotated[
2155
2213
  list[str] | None,
2156
2214
  typer.Option("--san", "-d", help="Additional SAN; repeat for more."),
2157
2215
  ] = None,
2216
+ wildcard: Annotated[
2217
+ bool,
2218
+ typer.Option(
2219
+ "--wildcard",
2220
+ "-w",
2221
+ help="Also request *.<domain> as a SAN (shortcut for --san '*.<domain>').",
2222
+ ),
2223
+ ] = False,
2158
2224
  email: Annotated[
2159
2225
  str | None, typer.Option("--email", help="ACME account contact email.")
2160
2226
  ] = None,
@@ -2172,6 +2238,7 @@ def cert_issue(
2172
2238
  provider: ProviderOption = CertProvider.letsencrypt,
2173
2239
  eab_kid: EabKidOption = None,
2174
2240
  eab_hmac_key: EabHmacKeyOption = None,
2241
+ preferred_chain: PreferredChainOption = None,
2175
2242
  force: Annotated[
2176
2243
  bool,
2177
2244
  typer.Option(
@@ -2197,6 +2264,9 @@ def cert_issue(
2197
2264
  default, or ZeroSSL with --provider zerossl)."""
2198
2265
 
2199
2266
  state = state_from(ctx)
2267
+ sans = list(san or [])
2268
+ if wildcard and f"*.{domain}" not in sans:
2269
+ sans.append(f"*.{domain}")
2200
2270
  out_dir = _cert_dir(domain)
2201
2271
  cert_path = out_dir / "cert.pem"
2202
2272
  if (
@@ -2212,12 +2282,13 @@ def cert_issue(
2212
2282
  _issue_certificate(
2213
2283
  state,
2214
2284
  domain,
2215
- sans=list(san or []),
2285
+ sans=sans,
2216
2286
  email=email,
2217
2287
  key_type=key_type.value,
2218
2288
  provider=provider.value,
2219
2289
  eab_kid=eab_kid,
2220
2290
  eab_hmac_key=eab_hmac_key,
2291
+ preferred_chain=preferred_chain.value if preferred_chain else None,
2221
2292
  staging=staging,
2222
2293
  out_dir=out_dir,
2223
2294
  export_path=out,
@@ -2268,6 +2339,7 @@ def cert_renew(
2268
2339
  provider: ProviderOption = CertProvider.letsencrypt,
2269
2340
  eab_kid: EabKidOption = None,
2270
2341
  eab_hmac_key: EabHmacKeyOption = None,
2342
+ preferred_chain: PreferredChainOption = None,
2271
2343
  verbose: Annotated[
2272
2344
  bool,
2273
2345
  typer.Option("--verbose", "-v", help="Show detailed ACME progress."),
@@ -2295,13 +2367,14 @@ def cert_renew(
2295
2367
  _issue_certificate(
2296
2368
  state,
2297
2369
  name,
2298
- sans=[],
2370
+ sans=[s for s in _cert_sans(cert_path) if s != name],
2299
2371
  email=email,
2300
2372
  key_type=key_type.value,
2301
2373
  staging=staging,
2302
2374
  provider=provider.value,
2303
2375
  eab_kid=eab_kid,
2304
2376
  eab_hmac_key=eab_hmac_key,
2377
+ preferred_chain=preferred_chain.value if preferred_chain else None,
2305
2378
  out_dir=out_dir,
2306
2379
  verbose=verbose,
2307
2380
  )
@@ -2377,7 +2450,7 @@ def cert_export(
2377
2450
  if not (privkey.is_file() and leaf.is_file()):
2378
2451
  raise CliError(
2379
2452
  f"no certificate for {domain} in {cert_dir}; "
2380
- "issue one with `dncli cert issue`"
2453
+ "issue one with `dncli certs issue`"
2381
2454
  )
2382
2455
  key_text = privkey.read_text()
2383
2456
  leaf_text = leaf.read_text()
@@ -2406,6 +2479,28 @@ def cert_export(
2406
2479
  sys.stdout.write(_combined_pem(key_text, leaf_text, chain_text))
2407
2480
 
2408
2481
 
2482
+ @cert_app.command("delete")
2483
+ def cert_delete(
2484
+ domain: Annotated[
2485
+ str, typer.Argument(help="Domain of a previously issued certificate.")
2486
+ ],
2487
+ yes: Annotated[
2488
+ bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")
2489
+ ] = False,
2490
+ ) -> None:
2491
+ """Delete a stored certificate and its private key from this machine.
2492
+
2493
+ This only removes the local files; the certificate itself is not revoked.
2494
+ """
2495
+
2496
+ cert_dir = _cert_dir(domain)
2497
+ if not cert_dir.is_dir():
2498
+ raise CliError(f"no certificate for {domain} in {cert_dir}")
2499
+ _confirm(f"Delete the certificate and private key for {domain}?", yes)
2500
+ shutil.rmtree(cert_dir)
2501
+ err_console.print(f"deleted {cert_dir}")
2502
+
2503
+
2409
2504
  # ---------------------------------------------------------------------------
2410
2505
  # Generated commands. tools/generate.py renders one thin command per
2411
2506
  # OpenAPI operation between the markers below, from openapi.json plus
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.7.0"
3
+ version = "0.9.0"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -11,7 +11,7 @@ dependencies = [
11
11
  "httpx>=0.27",
12
12
  "rich>=13",
13
13
  "cryptography>=42",
14
- "devnomads[acme]>=0.2.5",
14
+ "devnomads[acme]>=0.5.0",
15
15
  ]
16
16
 
17
17
  [project.scripts]
@@ -49,7 +49,7 @@ def captured(monkeypatch):
49
49
  return calls
50
50
 
51
51
 
52
- def _write_cert(cert_dir, *, days, with_key_chain=False):
52
+ def _write_cert(cert_dir, *, days, with_key_chain=False, sans=("example.com",)):
53
53
  """Write a self-signed leaf (and optionally key+chain) into ``cert_dir``,
54
54
  valid until ``days`` from now."""
55
55
 
@@ -72,7 +72,8 @@ def _write_cert(cert_dir, *, days, with_key_chain=False):
72
72
  .not_valid_before(now - datetime.timedelta(days=1))
73
73
  .not_valid_after(now + datetime.timedelta(days=days))
74
74
  .add_extension(
75
- x509.SubjectAlternativeName([x509.DNSName("example.com")]), critical=False
75
+ x509.SubjectAlternativeName([x509.DNSName(s) for s in sans]),
76
+ critical=False,
76
77
  )
77
78
  .sign(key, hashes.SHA256())
78
79
  )
@@ -90,10 +91,28 @@ def _write_cert(cert_dir, *, days, with_key_chain=False):
90
91
  return cert
91
92
 
92
93
 
93
- def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_config):
94
+ def test_cert_singular_resolves_to_certs(configured, captured, isolated_config):
95
+ # `certs` is the registered name; the singular works as a unique prefix,
96
+ # so this fails if another top-level command starting with `cert` appears
94
97
  result = runner.invoke(app, ["cert", "issue", "example.com"])
95
98
  assert result.exit_code == 0, result.output
96
99
  assert captured["challenge"] == "dns-01"
100
+
101
+
102
+ def test_certs_is_the_advertised_name():
103
+ import re
104
+
105
+ result = runner.invoke(app, ["--help"])
106
+ assert result.exit_code == 0, result.output
107
+ assert "certs" in result.output
108
+ # the singular alias must not be listed as a command of its own
109
+ assert not re.search(r"^\s*│?\s*cert\s", result.output, re.MULTILINE)
110
+
111
+
112
+ def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_config):
113
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
114
+ assert result.exit_code == 0, result.output
115
+ assert captured["challenge"] == "dns-01"
97
116
  assert captured["kwargs"]["dns_provider"] is not None
98
117
  out_dir = dncli.config_dir() / "certs" / "example.com"
99
118
  assert (out_dir / "cert.pem").read_text() == LEAF
@@ -119,7 +138,7 @@ def test_issue_default_key_type_is_ecdsa384(
119
138
  ):
120
139
  seen = {}
121
140
  _spy_generate_key(monkeypatch, seen)
122
- result = runner.invoke(app, ["cert", "issue", "example.com"])
141
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
123
142
  assert result.exit_code == 0, result.output
124
143
  assert seen["algo"] == "ecdsa384"
125
144
 
@@ -128,7 +147,7 @@ def test_issue_key_type_ecdsa521(configured, captured, isolated_config, monkeypa
128
147
  seen = {}
129
148
  _spy_generate_key(monkeypatch, seen)
130
149
  result = runner.invoke(
131
- app, ["cert", "issue", "example.com", "--key-type", "ecdsa521"]
150
+ app, ["certs", "issue", "example.com", "--key-type", "ecdsa521"]
132
151
  )
133
152
  assert result.exit_code == 0, result.output
134
153
  assert seen["algo"] == "ecdsa521"
@@ -140,7 +159,7 @@ def test_issue_key_type_is_selectable(
140
159
  seen = {}
141
160
  _spy_generate_key(monkeypatch, seen)
142
161
  result = runner.invoke(
143
- app, ["cert", "issue", "example.com", "--key-type", "rsa4096"]
162
+ app, ["certs", "issue", "example.com", "--key-type", "rsa4096"]
144
163
  )
145
164
  assert result.exit_code == 0, result.output
146
165
  assert seen["algo"] == "rsa4096"
@@ -148,14 +167,14 @@ def test_issue_key_type_is_selectable(
148
167
 
149
168
  def test_issue_rejects_unknown_key_type():
150
169
  result = runner.invoke(
151
- app, ["cert", "issue", "example.com", "--key-type", "ed25519"]
170
+ app, ["certs", "issue", "example.com", "--key-type", "ed25519"]
152
171
  )
153
172
  assert result.exit_code != 0
154
173
  assert "ed25519" in result.output
155
174
 
156
175
 
157
176
  def test_issue_is_always_dns01(configured, captured, isolated_config):
158
- result = runner.invoke(app, ["cert", "issue", "example.com"])
177
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
159
178
  assert result.exit_code == 0, result.output
160
179
  assert captured["challenge"] == "dns-01"
161
180
  assert captured["kwargs"]["dns_provider"] is not None
@@ -164,7 +183,7 @@ def test_issue_is_always_dns01(configured, captured, isolated_config):
164
183
  def test_issue_rejects_http01_options(configured, isolated_config):
165
184
  # HTTP-01 was removed; these flags must no longer be accepted.
166
185
  for flag in (["--http-01"], ["--standalone"], ["--webroot", "/tmp/x"]):
167
- result = runner.invoke(app, ["cert", "issue", "example.com", *flag])
186
+ result = runner.invoke(app, ["certs", "issue", "example.com", *flag])
168
187
  assert result.exit_code != 0, f"{flag} should be rejected: {result.output}"
169
188
 
170
189
 
@@ -172,7 +191,7 @@ def test_issue_passes_sans(configured, captured, isolated_config):
172
191
  result = runner.invoke(
173
192
  app,
174
193
  [
175
- "cert",
194
+ "certs",
176
195
  "issue",
177
196
  "example.com",
178
197
  "-d",
@@ -185,10 +204,52 @@ def test_issue_passes_sans(configured, captured, isolated_config):
185
204
  assert captured["kwargs"]["sans"] == ["www.example.com", "a.example.com"]
186
205
 
187
206
 
207
+ def test_issue_rejects_wildcard_cn(configured, captured, isolated_config):
208
+ result = runner.invoke(app, ["certs", "issue", "*.example.com"])
209
+ assert result.exit_code != 0
210
+ assert "certs issue example.com --wildcard" in result.output
211
+ assert "challenge" not in captured # obtain_certificate was not called
212
+
213
+
214
+ def test_issue_accepts_wildcard_as_san(configured, captured, isolated_config):
215
+ result = runner.invoke(
216
+ app, ["certs", "issue", "example.com", "--san", "*.example.com"]
217
+ )
218
+ assert result.exit_code == 0, result.output
219
+ assert captured["domain"] == "example.com"
220
+ assert captured["kwargs"]["sans"] == ["*.example.com"]
221
+
222
+
223
+ def test_issue_wildcard_flag_adds_san(configured, captured, isolated_config):
224
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--wildcard"])
225
+ assert result.exit_code == 0, result.output
226
+ assert captured["domain"] == "example.com"
227
+ assert captured["kwargs"]["sans"] == ["*.example.com"]
228
+
229
+
230
+ def test_issue_wildcard_flag_dedupes_explicit_san(
231
+ configured, captured, isolated_config
232
+ ):
233
+ result = runner.invoke(
234
+ app,
235
+ ["certs", "issue", "example.com", "--wildcard", "--san", "*.example.com"],
236
+ )
237
+ assert result.exit_code == 0, result.output
238
+ assert captured["kwargs"]["sans"] == ["*.example.com"]
239
+
240
+
241
+ def test_issue_wildcard_flag_composes_with_sans(configured, captured, isolated_config):
242
+ result = runner.invoke(
243
+ app, ["certs", "issue", "example.com", "-w", "-d", "www.example.com"]
244
+ )
245
+ assert result.exit_code == 0, result.output
246
+ assert captured["kwargs"]["sans"] == ["www.example.com", "*.example.com"]
247
+
248
+
188
249
  def test_issue_staging_switches_directory(configured, captured, isolated_config):
189
250
  import devnomads.acme as acme
190
251
 
191
- result = runner.invoke(app, ["cert", "issue", "example.com", "--staging"])
252
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--staging"])
192
253
  assert result.exit_code == 0, result.output
193
254
  assert captured["directory_url"] == acme.LETSENCRYPT_STAGING_DIRECTORY_URL
194
255
 
@@ -196,7 +257,7 @@ def test_issue_staging_switches_directory(configured, captured, isolated_config)
196
257
  def test_issue_defaults_to_letsencrypt(configured, captured, isolated_config):
197
258
  import devnomads.acme as acme
198
259
 
199
- result = runner.invoke(app, ["cert", "issue", "example.com"])
260
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
200
261
  assert result.exit_code == 0, result.output
201
262
  assert captured["directory_url"] == acme.LETSENCRYPT_DIRECTORY_URL
202
263
  # Let's Encrypt keeps the original account key path for back-compat
@@ -224,7 +285,7 @@ def test_issue_zerossl_uses_directory_and_eab(configured, captured, isolated_con
224
285
  result = runner.invoke(
225
286
  app,
226
287
  [
227
- "cert",
288
+ "certs",
228
289
  "issue",
229
290
  "example.com",
230
291
  "--provider",
@@ -251,7 +312,7 @@ def test_issue_zerossl_reads_eab_from_env(
251
312
  monkeypatch.setenv(dncli.ENV_EAB_KID, "kid-env")
252
313
  monkeypatch.setenv(dncli.ENV_EAB_HMAC_KEY, "hmac-env")
253
314
  result = runner.invoke(
254
- app, ["cert", "issue", "example.com", "--provider", "zerossl"]
315
+ app, ["certs", "issue", "example.com", "--provider", "zerossl"]
255
316
  )
256
317
  assert result.exit_code == 0, result.output
257
318
  assert captured["directory_url"] == acme.ZEROSSL_DIRECTORY_URL
@@ -259,7 +320,7 @@ def test_issue_zerossl_reads_eab_from_env(
259
320
 
260
321
  def test_issue_zerossl_without_eab_errors(configured, captured, isolated_config):
261
322
  result = runner.invoke(
262
- app, ["cert", "issue", "example.com", "--provider", "zerossl"]
323
+ app, ["certs", "issue", "example.com", "--provider", "zerossl"]
263
324
  )
264
325
  assert result.exit_code == 1
265
326
  assert "external account binding" in result.output
@@ -269,7 +330,7 @@ def test_issue_zerossl_without_eab_errors(configured, captured, isolated_config)
269
330
  def test_issue_zerossl_with_staging_errors(configured, captured, isolated_config):
270
331
  result = runner.invoke(
271
332
  app,
272
- ["cert", "issue", "example.com", "--provider", "zerossl", "--staging"],
333
+ ["certs", "issue", "example.com", "--provider", "zerossl", "--staging"],
273
334
  )
274
335
  assert result.exit_code == 1
275
336
  assert "--staging only applies to the letsencrypt provider" in result.output
@@ -279,7 +340,7 @@ def test_issue_out_exports_combined_pem(
279
340
  configured, captured, isolated_config, tmp_path
280
341
  ):
281
342
  out = tmp_path / "bundle.pem"
282
- result = runner.invoke(app, ["cert", "issue", "example.com", "--out", str(out)])
343
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--out", str(out)])
283
344
  assert result.exit_code == 0, result.output
284
345
  # the default per-domain dir is still written (with the full chain)...
285
346
  default_dir = dncli.config_dir() / "certs" / "example.com"
@@ -371,7 +432,7 @@ def test_load_acme_import_error_message(monkeypatch):
371
432
  return real_import(name, *args, **kwargs)
372
433
 
373
434
  monkeypatch.setattr(builtins, "__import__", fake_import)
374
- result = runner.invoke(app, ["cert", "issue", "example.com"])
435
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
375
436
  assert result.exit_code == 1
376
437
  assert "devnomads.acme" in result.output
377
438
 
@@ -405,7 +466,7 @@ def test_renew_skips_valid_cert(configured, captured, isolated_config):
405
466
  out_dir.mkdir(parents=True)
406
467
  (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
407
468
 
408
- result = runner.invoke(app, ["cert", "renew", "example.com"])
469
+ result = runner.invoke(app, ["certs", "renew", "example.com"])
409
470
  assert result.exit_code == 0, result.output
410
471
  assert "still valid" in result.output
411
472
  assert "challenge" not in captured # obtain_certificate was not called
@@ -439,15 +500,38 @@ def test_renew_reissues_expiring_cert(configured, captured, isolated_config):
439
500
  out_dir.mkdir(parents=True)
440
501
  (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
441
502
 
442
- result = runner.invoke(app, ["cert", "renew", "example.com"])
503
+ result = runner.invoke(app, ["certs", "renew", "example.com"])
443
504
  assert result.exit_code == 0, result.output
444
505
  assert captured["challenge"] == "dns-01"
445
506
  assert (out_dir / "fullchain.pem").read_text() == FULLCHAIN
446
507
 
447
508
 
509
+ def test_renew_preserves_sans(configured, captured, isolated_config):
510
+ _write_cert(
511
+ dncli.config_dir() / "certs" / "example.com",
512
+ days=5,
513
+ sans=("example.com", "*.example.com", "www.example.com"),
514
+ )
515
+ result = runner.invoke(app, ["certs", "renew", "example.com"])
516
+ assert result.exit_code == 0, result.output
517
+ assert captured["kwargs"]["sans"] == ["*.example.com", "www.example.com"]
518
+
519
+
520
+ def test_renew_rejects_wildcard_cn(configured, captured, isolated_config):
521
+ # a legacy cert dir issued with a wildcard CN must not renew as-is
522
+ _write_cert(
523
+ dncli.config_dir() / "certs" / "*.example.com",
524
+ days=5,
525
+ sans=("*.example.com",),
526
+ )
527
+ result = runner.invoke(app, ["certs", "renew", "*.example.com"])
528
+ assert result.exit_code != 0
529
+ assert "challenge" not in captured # obtain_certificate was not called
530
+
531
+
448
532
  def test_issue_skips_when_valid_beyond_window(configured, captured, isolated_config):
449
533
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=60)
450
- result = runner.invoke(app, ["cert", "issue", "example.com"])
534
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
451
535
  assert result.exit_code == 0, result.output
452
536
  assert "still valid" in result.output
453
537
  assert "challenge" not in captured # obtain_certificate was not called
@@ -455,14 +539,14 @@ def test_issue_skips_when_valid_beyond_window(configured, captured, isolated_con
455
539
 
456
540
  def test_issue_force_reissues_valid_cert(configured, captured, isolated_config):
457
541
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=60)
458
- result = runner.invoke(app, ["cert", "issue", "example.com", "--force"])
542
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--force"])
459
543
  assert result.exit_code == 0, result.output
460
544
  assert captured["challenge"] == "dns-01"
461
545
 
462
546
 
463
547
  def test_issue_reissues_when_within_window(configured, captured, isolated_config):
464
548
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=10)
465
- result = runner.invoke(app, ["cert", "issue", "example.com"])
549
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
466
550
  assert result.exit_code == 0, result.output
467
551
  assert captured["challenge"] == "dns-01"
468
552
 
@@ -471,7 +555,7 @@ def test_cert_list_shows_issued(configured, isolated_config):
471
555
  import json
472
556
 
473
557
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=45)
474
- result = runner.invoke(app, ["cert", "list", "--out", "json"])
558
+ result = runner.invoke(app, ["certs", "list", "--out", "json"])
475
559
  assert result.exit_code == 0, result.output
476
560
  rows = json.loads(result.output)
477
561
  assert rows[0]["domain"] == "example.com"
@@ -486,7 +570,7 @@ def test_cert_list_shows_recorded_provider(configured, isolated_config):
486
570
  cert_dir = dncli.config_dir() / "certs" / "example.com"
487
571
  _write_cert(cert_dir, days=45)
488
572
  (cert_dir / "provider").write_text("zerossl\n")
489
- result = runner.invoke(app, ["cert", "list", "--out", "json"])
573
+ result = runner.invoke(app, ["certs", "list", "--out", "json"])
490
574
  assert result.exit_code == 0, result.output
491
575
  assert json.loads(result.output)[0]["provider"] == "zerossl"
492
576
 
@@ -523,7 +607,7 @@ def test_provider_inferred_from_issuer_when_unrecorded():
523
607
 
524
608
 
525
609
  def test_cert_list_empty(isolated_config):
526
- result = runner.invoke(app, ["cert", "list"])
610
+ result = runner.invoke(app, ["certs", "list"])
527
611
  assert result.exit_code == 0, result.output
528
612
  assert "no certificates" in result.output
529
613
 
@@ -533,7 +617,7 @@ def test_cert_export_to_file(configured, isolated_config, tmp_path):
533
617
  dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
534
618
  )
535
619
  out = tmp_path / "bundle.pem"
536
- result = runner.invoke(app, ["cert", "export", "example.com", "--out", str(out)])
620
+ result = runner.invoke(app, ["certs", "export", "example.com", "--out", str(out)])
537
621
  assert result.exit_code == 0, result.output
538
622
  text = out.read_text()
539
623
  # order: private key, then leaf certificate, then the one intermediate
@@ -548,7 +632,7 @@ def test_cert_export_to_stdout(configured, isolated_config):
548
632
  _write_cert(
549
633
  dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
550
634
  )
551
- result = runner.invoke(app, ["cert", "export", "example.com"])
635
+ result = runner.invoke(app, ["certs", "export", "example.com"])
552
636
  assert result.exit_code == 0, result.output
553
637
  assert "BEGIN CERTIFICATE" in result.stdout
554
638
  assert "INTER" in result.stdout
@@ -556,7 +640,7 @@ def test_cert_export_to_stdout(configured, isolated_config):
556
640
 
557
641
 
558
642
  def test_cert_export_missing(isolated_config):
559
- result = runner.invoke(app, ["cert", "export", "nope.com"])
643
+ result = runner.invoke(app, ["certs", "export", "nope.com"])
560
644
  assert result.exit_code != 0
561
645
  assert "no certificate" in result.output
562
646
 
@@ -568,7 +652,7 @@ def test_cert_export_passphrase_rejected_for_pem(configured, isolated_config, tm
568
652
  result = runner.invoke(
569
653
  app,
570
654
  [
571
- "cert",
655
+ "certs",
572
656
  "export",
573
657
  "example.com",
574
658
  "--passphrase",
@@ -633,7 +717,7 @@ def test_cert_export_pfx_no_passphrase(configured, isolated_config, tmp_path):
633
717
  leaf, inter, root = _write_real_chain(dncli.config_dir() / "certs" / "example.com")
634
718
  out = tmp_path / "bundle.pfx"
635
719
  result = runner.invoke(
636
- app, ["cert", "export", "example.com", "--format", "pfx", "--out", str(out)]
720
+ app, ["certs", "export", "example.com", "--format", "pfx", "--out", str(out)]
637
721
  )
638
722
  assert result.exit_code == 0, result.output
639
723
  key, cert, cas = pkcs12.load_key_and_certificates(out.read_bytes(), None)
@@ -652,7 +736,7 @@ def test_cert_export_pfx_with_passphrase(configured, isolated_config, tmp_path):
652
736
  result = runner.invoke(
653
737
  app,
654
738
  [
655
- "cert",
739
+ "certs",
656
740
  "export",
657
741
  "example.com",
658
742
  "--format",
@@ -672,6 +756,30 @@ def test_cert_export_pfx_with_passphrase(configured, isolated_config, tmp_path):
672
756
 
673
757
  def test_cert_export_pfx_requires_out(configured, isolated_config):
674
758
  _write_real_chain(dncli.config_dir() / "certs" / "example.com")
675
- result = runner.invoke(app, ["cert", "export", "example.com", "--format", "pfx"])
759
+ result = runner.invoke(app, ["certs", "export", "example.com", "--format", "pfx"])
676
760
  assert result.exit_code != 0
677
761
  assert "out" in result.output.lower()
762
+
763
+
764
+ def test_cert_delete_removes_stored_cert(isolated_config):
765
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
766
+ _write_cert(cert_dir, days=45, with_key_chain=True)
767
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--yes"])
768
+ assert result.exit_code == 0, result.output
769
+ assert not cert_dir.exists()
770
+
771
+
772
+ def test_cert_delete_missing(isolated_config):
773
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--yes"])
774
+ assert result.exit_code != 0
775
+ assert "no certificate for example.com" in result.output
776
+
777
+
778
+ def test_cert_delete_requires_confirmation(isolated_config):
779
+ # without --yes and without a TTY the command must refuse and keep the files
780
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
781
+ _write_cert(cert_dir, days=45, with_key_chain=True)
782
+ result = runner.invoke(app, ["certs", "delete", "example.com"])
783
+ assert result.exit_code != 0
784
+ assert "confirmation required" in result.output
785
+ assert (cert_dir / "privkey.pem").is_file()
File without changes
File without changes