devnomads-cli 0.3.0__tar.gz → 0.4.1__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.3.0 → devnomads_cli-0.4.1}/PKG-INFO +14 -8
  2. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/README.md +12 -4
  3. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/devnomads_cli.egg-info/PKG-INFO +14 -8
  4. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/devnomads_cli.egg-info/requires.txt +1 -4
  5. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/dncli.py +38 -16
  6. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/pyproject.toml +2 -5
  7. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_cert.py +52 -2
  8. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_config.py +2 -2
  9. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/LICENSE +0 -0
  10. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  11. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  12. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
  13. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/devnomads_cli.egg-info/top_level.txt +0 -0
  14. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/setup.cfg +0 -0
  15. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_cli.py +0 -0
  16. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.3.0 → devnomads_cli-0.4.1}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -11,9 +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>=0.1
15
- Provides-Extra: cert
16
- Requires-Dist: devnomads[acme]>=0.1; extra == "cert"
14
+ Requires-Dist: devnomads[acme]>=0.2.3
17
15
  Dynamic: license-file
18
16
 
19
17
  # dncli
@@ -56,7 +54,7 @@ Sleutels**, then store it:
56
54
  dncli configure
57
55
  ```
58
56
 
59
- The key is written to `~/.config/dnctl/credentials`, readable only
57
+ The key is written to `~/.config/dncli/credentials`, readable only
60
58
  by you. From here every command works:
61
59
 
62
60
  ```sh
@@ -128,13 +126,21 @@ dncli services list | jq -r '.[].entity'
128
126
 
129
127
  ## Certificates
130
128
 
131
- The `cert` extra adds Let's Encrypt issuance over DNS-01 and HTTP-01:
129
+ `dncli` issues Let's Encrypt certificates over DNS-01 and HTTP-01:
132
130
 
133
131
  ```sh
134
- uv tool install "devnomads-cli[cert]"
135
- dncli cert issue example.com -d "*.example.com"
132
+ dncli cert issue example.com -d www.example.com -d "*.example.com"
136
133
  ```
137
134
 
135
+ The first argument is the primary domain (the certificate CN); add
136
+ extra names (SANs) by repeating `--san`/`-d`. The certificate is
137
+ written to `~/.config/dncli/certs/<domain>/` as `cert.pem`,
138
+ `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600); override the
139
+ location with `--out`.
140
+
141
+ Keys are ECDSA P-384 by default. Pick another with `--key-type`
142
+ (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
143
+
138
144
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
139
145
 
140
146
  ```sh
@@ -38,7 +38,7 @@ Sleutels**, then store it:
38
38
  dncli configure
39
39
  ```
40
40
 
41
- The key is written to `~/.config/dnctl/credentials`, readable only
41
+ The key is written to `~/.config/dncli/credentials`, readable only
42
42
  by you. From here every command works:
43
43
 
44
44
  ```sh
@@ -110,13 +110,21 @@ dncli services list | jq -r '.[].entity'
110
110
 
111
111
  ## Certificates
112
112
 
113
- The `cert` extra adds Let's Encrypt issuance over DNS-01 and HTTP-01:
113
+ `dncli` issues Let's Encrypt certificates over DNS-01 and HTTP-01:
114
114
 
115
115
  ```sh
116
- uv tool install "devnomads-cli[cert]"
117
- dncli cert issue example.com -d "*.example.com"
116
+ dncli cert issue example.com -d www.example.com -d "*.example.com"
118
117
  ```
119
118
 
119
+ The first argument is the primary domain (the certificate CN); add
120
+ extra names (SANs) by repeating `--san`/`-d`. The certificate is
121
+ written to `~/.config/dncli/certs/<domain>/` as `cert.pem`,
122
+ `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600); override the
123
+ location with `--out`.
124
+
125
+ Keys are ECDSA P-384 by default. Pick another with `--key-type`
126
+ (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
127
+
120
128
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
121
129
 
122
130
  ```sh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -11,9 +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>=0.1
15
- Provides-Extra: cert
16
- Requires-Dist: devnomads[acme]>=0.1; extra == "cert"
14
+ Requires-Dist: devnomads[acme]>=0.2.3
17
15
  Dynamic: license-file
18
16
 
19
17
  # dncli
@@ -56,7 +54,7 @@ Sleutels**, then store it:
56
54
  dncli configure
57
55
  ```
58
56
 
59
- The key is written to `~/.config/dnctl/credentials`, readable only
57
+ The key is written to `~/.config/dncli/credentials`, readable only
60
58
  by you. From here every command works:
61
59
 
62
60
  ```sh
@@ -128,13 +126,21 @@ dncli services list | jq -r '.[].entity'
128
126
 
129
127
  ## Certificates
130
128
 
131
- The `cert` extra adds Let's Encrypt issuance over DNS-01 and HTTP-01:
129
+ `dncli` issues Let's Encrypt certificates over DNS-01 and HTTP-01:
132
130
 
133
131
  ```sh
134
- uv tool install "devnomads-cli[cert]"
135
- dncli cert issue example.com -d "*.example.com"
132
+ dncli cert issue example.com -d www.example.com -d "*.example.com"
136
133
  ```
137
134
 
135
+ The first argument is the primary domain (the certificate CN); add
136
+ extra names (SANs) by repeating `--san`/`-d`. The certificate is
137
+ written to `~/.config/dncli/certs/<domain>/` as `cert.pem`,
138
+ `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600); override the
139
+ location with `--out`.
140
+
141
+ Keys are ECDSA P-384 by default. Pick another with `--key-type`
142
+ (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
143
+
138
144
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
139
145
 
140
146
  ```sh
@@ -2,7 +2,4 @@ typer>=0.12
2
2
  httpx>=0.27
3
3
  rich>=13
4
4
  cryptography>=42
5
- devnomads>=0.1
6
-
7
- [cert]
8
- devnomads[acme]>=0.1
5
+ devnomads[acme]>=0.2.3
@@ -44,6 +44,7 @@ from devnomads.api import DevNomadsError
44
44
  from devnomads.api.client import _unwrap as _lib_unwrap
45
45
  from devnomads.dns import Dns, challenge_name
46
46
  from rich.console import Console
47
+ from rich.markup import escape
47
48
  from rich.table import Table
48
49
  from typer.core import TyperGroup
49
50
 
@@ -71,7 +72,7 @@ class CliError(typer.Exit):
71
72
 
72
73
  def __init__(self, message: str) -> None:
73
74
  # soft_wrap keeps the error a single grep-able line
74
- err_console.print(f"[red]error:[/] {message}", soft_wrap=True)
75
+ err_console.print(f"[red]error:[/] {escape(message)}", soft_wrap=True)
75
76
  self.message = message
76
77
  super().__init__(code=1)
77
78
 
@@ -85,7 +86,7 @@ def config_dir() -> Path:
85
86
  return Path(env_dir)
86
87
  xdg = os.environ.get("XDG_CONFIG_HOME")
87
88
  base = Path(xdg) if xdg else Path.home() / ".config"
88
- return base / "dnctl"
89
+ return base / "dncli"
89
90
 
90
91
 
91
92
  def credentials_path() -> Path:
@@ -1275,10 +1276,12 @@ def _hook_dispatch(dns: Dns, operation: str, args: list[str]) -> int:
1275
1276
  else:
1276
1277
  dns.unset_txt(record, token_value)
1277
1278
  except (AuthError, ApiError) as exc:
1278
- err_console.print(f"[red]error:[/] {_error_message(exc.status, exc.detail)}")
1279
+ err_console.print(
1280
+ f"[red]error:[/] {escape(_error_message(exc.status, exc.detail))}"
1281
+ )
1279
1282
  return 1
1280
1283
  except DevNomadsError as exc:
1281
- err_console.print(f"[red]error:[/] {exc}")
1284
+ err_console.print(f"[red]error:[/] {escape(str(exc))}")
1282
1285
  return 1
1283
1286
  return 0
1284
1287
 
@@ -1306,7 +1309,7 @@ def hook_main(argv: list[str] | None = None) -> int:
1306
1309
  try:
1307
1310
  client = ApiClient.from_environment()
1308
1311
  except DevNomadsError as exc:
1309
- err_console.print(f"[red]error:[/] {exc}")
1312
+ err_console.print(f"[red]error:[/] {escape(str(exc))}")
1310
1313
  return 1
1311
1314
  try:
1312
1315
  return _hook_dispatch(Dns(client), operation, argv[2:])
@@ -1359,17 +1362,26 @@ LE_STAGING_DIRECTORY = "https://acme-staging-v02.api.letsencrypt.org/directory"
1359
1362
  CERT_RENEW_WINDOW_DAYS = 30
1360
1363
 
1361
1364
 
1365
+ class KeyType(str, Enum):
1366
+ """Certificate key types. ECDSA is the default; RSA on request."""
1367
+
1368
+ ecdsa256 = "ecdsa256"
1369
+ ecdsa384 = "ecdsa384"
1370
+ ecdsa521 = "ecdsa521"
1371
+ rsa2048 = "rsa2048"
1372
+ rsa4096 = "rsa4096"
1373
+
1374
+
1362
1375
  def _load_acme() -> Any:
1363
- """Import devnomads.acme lazily, with a clean message if the extra is
1364
- missing."""
1376
+ """Import devnomads.acme lazily, so non-cert commands skip the ACME
1377
+ stack at startup."""
1365
1378
 
1366
1379
  try:
1367
1380
  import devnomads.acme as acme
1368
1381
  except ImportError as exc:
1369
1382
  raise CliError(
1370
- "certificate issuance needs the 'cert' extra; install it with: "
1371
- 'pip install "devnomads-cli[cert]" '
1372
- '(or: uv tool install "devnomads-cli[cert]")'
1383
+ "could not import devnomads.acme; the install looks broken - "
1384
+ 'reinstall with: pip install --force-reinstall "devnomads-cli"'
1373
1385
  ) from exc
1374
1386
  return acme
1375
1387
 
@@ -1496,8 +1508,13 @@ def cert_issue(
1496
1508
  str | None, typer.Option("--email", help="ACME account contact email.")
1497
1509
  ] = None,
1498
1510
  key_type: Annotated[
1499
- str, typer.Option("--key-type", help="Certificate key type.")
1500
- ] = "ec256",
1511
+ KeyType,
1512
+ typer.Option(
1513
+ "--key-type",
1514
+ help="Certificate key type "
1515
+ "(ecdsa256/ecdsa384/ecdsa521/rsa2048/rsa4096).",
1516
+ ),
1517
+ ] = KeyType.ecdsa384,
1501
1518
  staging: Annotated[
1502
1519
  bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
1503
1520
  ] = False,
@@ -1526,7 +1543,7 @@ def cert_issue(
1526
1543
  webroot=webroot,
1527
1544
  standalone=standalone,
1528
1545
  email=email,
1529
- key_type=key_type,
1546
+ key_type=key_type.value,
1530
1547
  staging=staging,
1531
1548
  out_dir=_cert_out_dir(out, domain),
1532
1549
  )
@@ -1562,8 +1579,13 @@ def cert_renew(
1562
1579
  str | None, typer.Option("--email", help="ACME account contact email.")
1563
1580
  ] = None,
1564
1581
  key_type: Annotated[
1565
- str, typer.Option("--key-type", help="Certificate key type.")
1566
- ] = "ec256",
1582
+ KeyType,
1583
+ typer.Option(
1584
+ "--key-type",
1585
+ help="Certificate key type "
1586
+ "(ecdsa256/ecdsa384/ecdsa521/rsa2048/rsa4096).",
1587
+ ),
1588
+ ] = KeyType.ecdsa384,
1567
1589
  staging: Annotated[
1568
1590
  bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
1569
1591
  ] = False,
@@ -1595,7 +1617,7 @@ def cert_renew(
1595
1617
  webroot=None,
1596
1618
  standalone=False,
1597
1619
  email=email,
1598
- key_type=key_type,
1620
+ key_type=key_type.value,
1599
1621
  staging=staging,
1600
1622
  out_dir=out_dir,
1601
1623
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -11,12 +11,9 @@ dependencies = [
11
11
  "httpx>=0.27",
12
12
  "rich>=13",
13
13
  "cryptography>=42",
14
- "devnomads>=0.1",
14
+ "devnomads[acme]>=0.2.3",
15
15
  ]
16
16
 
17
- [project.optional-dependencies]
18
- cert = ["devnomads[acme]>=0.1"]
19
-
20
17
  [project.scripts]
21
18
  dncli = "dncli:run"
22
19
  dncli-dns-hook = "dncli:hook_main"
@@ -57,6 +57,56 @@ def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_co
57
57
  assert stat.S_IMODE(privkey.stat().st_mode) == 0o600
58
58
 
59
59
 
60
+ def _spy_generate_key(monkeypatch, seen):
61
+ import devnomads.acme as acme
62
+
63
+ def spy(algo):
64
+ seen["algo"] = algo
65
+ return object()
66
+
67
+ monkeypatch.setattr(acme, "generate_key", spy)
68
+
69
+
70
+ def test_issue_default_key_type_is_ecdsa384(
71
+ configured, captured, isolated_config, monkeypatch
72
+ ):
73
+ seen = {}
74
+ _spy_generate_key(monkeypatch, seen)
75
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
76
+ assert result.exit_code == 0, result.output
77
+ assert seen["algo"] == "ecdsa384"
78
+
79
+
80
+ def test_issue_key_type_ecdsa521(configured, captured, isolated_config, monkeypatch):
81
+ seen = {}
82
+ _spy_generate_key(monkeypatch, seen)
83
+ result = runner.invoke(
84
+ app, ["cert", "issue", "example.com", "--key-type", "ecdsa521"]
85
+ )
86
+ assert result.exit_code == 0, result.output
87
+ assert seen["algo"] == "ecdsa521"
88
+
89
+
90
+ def test_issue_key_type_is_selectable(
91
+ configured, captured, isolated_config, monkeypatch
92
+ ):
93
+ seen = {}
94
+ _spy_generate_key(monkeypatch, seen)
95
+ result = runner.invoke(
96
+ app, ["cert", "issue", "example.com", "--key-type", "rsa4096"]
97
+ )
98
+ assert result.exit_code == 0, result.output
99
+ assert seen["algo"] == "rsa4096"
100
+
101
+
102
+ def test_issue_rejects_unknown_key_type():
103
+ result = runner.invoke(
104
+ app, ["cert", "issue", "example.com", "--key-type", "ed25519"]
105
+ )
106
+ assert result.exit_code != 0
107
+ assert "ed25519" in result.output
108
+
109
+
60
110
  def test_issue_http01_webroot_uses_webroot_solver(
61
111
  configured, captured, isolated_config, tmp_path
62
112
  ):
@@ -119,7 +169,7 @@ def test_issue_out_dir_override(configured, captured, isolated_config, tmp_path)
119
169
  assert (out / "fullchain.pem").read_text() == FULLCHAIN
120
170
 
121
171
 
122
- def test_load_acme_missing_extra_message(monkeypatch):
172
+ def test_load_acme_import_error_message(monkeypatch):
123
173
  import builtins
124
174
 
125
175
  real_import = builtins.__import__
@@ -132,7 +182,7 @@ def test_load_acme_missing_extra_message(monkeypatch):
132
182
  monkeypatch.setattr(builtins, "__import__", fake_import)
133
183
  result = runner.invoke(app, ["cert", "issue", "example.com"])
134
184
  assert result.exit_code == 1
135
- assert "cert" in result.output
185
+ assert "devnomads.acme" in result.output
136
186
 
137
187
 
138
188
  def test_renew_skips_valid_cert(configured, captured, isolated_config):
@@ -16,14 +16,14 @@ def test_config_dir_from_env(isolated_config):
16
16
  def test_config_dir_xdg_fallback(monkeypatch, tmp_path):
17
17
  monkeypatch.delenv(dncli.ENV_CONFIG_DIR, raising=False)
18
18
  monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
19
- assert dncli.config_dir() == tmp_path / "xdg" / "dnctl"
19
+ assert dncli.config_dir() == tmp_path / "xdg" / "dncli"
20
20
 
21
21
 
22
22
  def test_config_dir_home_fallback(monkeypatch, tmp_path):
23
23
  monkeypatch.delenv(dncli.ENV_CONFIG_DIR, raising=False)
24
24
  monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
25
25
  monkeypatch.setattr(Path, "home", lambda: tmp_path)
26
- assert dncli.config_dir() == tmp_path / ".config" / "dnctl"
26
+ assert dncli.config_dir() == tmp_path / ".config" / "dncli"
27
27
 
28
28
 
29
29
  def test_save_credentials_sets_strict_permissions(write_profile):
File without changes
File without changes