devnomads-cli 0.5.8__tar.gz → 0.6.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.5.8 → devnomads_cli-0.6.0}/PKG-INFO +1 -1
  2. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/PKG-INFO +1 -1
  3. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/dncli.py +63 -11
  4. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/pyproject.toml +1 -1
  5. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_cli.py +92 -7
  6. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/LICENSE +0 -0
  7. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/README.md +0 -0
  8. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/setup.cfg +0 -0
  14. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_cert.py +0 -0
  15. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.5.8 → devnomads_cli-0.6.0}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.5.8 → devnomads_cli-0.6.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.5.8
3
+ Version: 0.6.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.5.8
3
+ Version: 0.6.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -27,6 +27,7 @@ import io
27
27
  import json
28
28
  import logging
29
29
  import os
30
+ import re
30
31
  import secrets
31
32
  import stat
32
33
  import sys
@@ -51,7 +52,7 @@ from rich.markup import escape
51
52
  from rich.table import Table
52
53
  from typer.core import TyperGroup
53
54
 
54
- __version__ = "0.5.8"
55
+ __version__ = "0.6.0"
55
56
 
56
57
  # Distribution names, for reporting versions via `dncli version`.
57
58
  CLI_PACKAGE = "devnomads-cli"
@@ -90,6 +91,10 @@ ENV_CONFIG_DIR = "DN_CONFIG_DIR"
90
91
  ENV_EAB_KID = "DN_ZEROSSL_EAB_KID"
91
92
  ENV_EAB_HMAC_KEY = "DN_ZEROSSL_EAB_HMAC_KEY"
92
93
  PROVIDERS = ("auroradns", "transip")
94
+ # DevNomads keys are a long alphanumeric token (Laravel Sanctum style),
95
+ # optionally carrying a numeric "<id>|" prefix. Matched loosely, for a
96
+ # warning only - never to reject a key outright.
97
+ API_KEY_RE = re.compile(r"^(?:\d+\|)?[A-Za-z0-9]{40,}$")
93
98
  REQUEST_TIMEOUT = 30.0
94
99
  RECORD_COLUMNS = ["name", "type", "ttl", "content", "disabled"]
95
100
 
@@ -1243,30 +1248,46 @@ def configure(
1243
1248
 
1244
1249
  if ctx.invoked_subcommand:
1245
1250
  return
1251
+ if provider is not None and provider not in PROVIDERS:
1252
+ raise CliError(
1253
+ f"unknown provider '{provider}' (available: {', '.join(PROVIDERS)})"
1254
+ )
1255
+
1256
+ profile = typer.prompt("Profile name", default=profile, err=True)
1257
+
1246
1258
  if provider is None:
1247
1259
  section = profile
1248
- values = {
1249
- "api_key": typer.prompt("DevNomads API key", hide_input=True, err=True)
1250
- }
1251
1260
  elif provider == "auroradns":
1252
1261
  section = f"auroradns:{profile}"
1262
+ else: # transip
1263
+ section = f"transip:{profile}"
1264
+
1265
+ parser = load_credentials()
1266
+ if parser.has_section(section) and not typer.confirm(
1267
+ f"profile '{section}' already exists; overwrite it?", err=True
1268
+ ):
1269
+ raise typer.Abort()
1270
+
1271
+ if provider is None:
1272
+ api_key = typer.prompt("DevNomads API key", hide_input=True, err=True)
1273
+ if not API_KEY_RE.match(api_key.strip()) and not typer.confirm(
1274
+ "that does not look like a DevNomads API key; use it anyway?", err=True
1275
+ ):
1276
+ raise typer.Abort()
1277
+ values = {"api_key": api_key}
1278
+ elif provider == "auroradns":
1253
1279
  values = {
1254
1280
  "api_key": typer.prompt("AuroraDNS API key", hide_input=True, err=True),
1255
1281
  "secret_key": typer.prompt(
1256
1282
  "AuroraDNS secret key", hide_input=True, err=True
1257
1283
  ),
1258
1284
  }
1259
- elif provider == "transip":
1260
- section = f"transip:{profile}"
1285
+ else: # transip
1261
1286
  login = typer.prompt("TransIP login name", err=True)
1262
1287
  key_source = typer.prompt("Path to TransIP private key (PEM)", err=True)
1263
1288
  key_path = _import_private_key(Path(key_source).expanduser(), profile)
1264
1289
  values = {"login": login, "private_key_file": str(key_path)}
1265
- else:
1266
- raise CliError(
1267
- f"unknown provider '{provider}' (available: {', '.join(PROVIDERS)})"
1268
- )
1269
- parser = load_credentials()
1290
+
1270
1291
  if not parser.has_section(section):
1271
1292
  parser.add_section(section)
1272
1293
  for key, value in values.items():
@@ -1274,6 +1295,37 @@ def configure(
1274
1295
  save_credentials(parser)
1275
1296
  err_console.print(f"profile '{section}' written to {credentials_path()}")
1276
1297
 
1298
+ if provider is None:
1299
+ _verify_devnomads_key(
1300
+ parser.get(section, "api_url", fallback=DEFAULT_API_URL),
1301
+ values["api_key"],
1302
+ )
1303
+
1304
+
1305
+ def _verify_devnomads_key(api_url: str, api_key: str) -> None:
1306
+ """Best-effort check that a freshly stored key actually authenticates.
1307
+
1308
+ There is no no-op auth endpoint, so this hits the cheapest one -
1309
+ GET /handles. A 401 is reported as a warning (the key is still stored);
1310
+ network or other errors are ignored so `configure` works offline.
1311
+ """
1312
+
1313
+ try:
1314
+ with ApiClient(
1315
+ api_url,
1316
+ api_key,
1317
+ user_agent=f"dncli/{__version__}",
1318
+ timeout=REQUEST_TIMEOUT,
1319
+ ) as client:
1320
+ client.request("GET", "/handles")
1321
+ except AuthError:
1322
+ err_console.print(
1323
+ "warning: the key was stored, but the API rejected it (401) - "
1324
+ "double-check the token"
1325
+ )
1326
+ except DevNomadsError:
1327
+ pass # offline or transient; can't verify, so don't complain
1328
+
1277
1329
 
1278
1330
  def _import_private_key(source: Path, profile: str) -> Path:
1279
1331
  try:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.5.8"
3
+ version = "0.6.0"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -5,7 +5,7 @@ import stat
5
5
 
6
6
  import pytest
7
7
  import respx
8
- from httpx import Response
8
+ from httpx import ConnectError, Response
9
9
  from typer.testing import CliRunner
10
10
 
11
11
  import dncli
@@ -14,6 +14,11 @@ from dncli import app
14
14
  API = "https://api.test"
15
15
  runner = CliRunner()
16
16
 
17
+ # a token shaped like a real DevNomads key (passes the loose format check)
18
+ VALID_KEY = "LL2bKLzNOQaEvKKcXVdfmdCteJPWmbNkWclmOOos514764bb"
19
+ # configure verifies a stored key against this endpoint at the default api_url
20
+ HANDLES = f"{dncli.DEFAULT_API_URL}/handles"
21
+
17
22
 
18
23
  def all_output(result):
19
24
  """Combined stdout + stderr; result.output mixes both on every
@@ -391,25 +396,105 @@ def test_unknown_profile_fails_clearly(configured):
391
396
  assert "'nope' not found" in all_output(result)
392
397
 
393
398
 
399
+ @respx.mock
394
400
  def test_configure_writes_profile(isolated_config):
395
- result = runner.invoke(app, ["configure"], input="dn_secretkey123456\n")
401
+ respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
402
+ # first prompt is the profile name; empty line accepts the "default" default
403
+ result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
396
404
  assert result.exit_code == 0
397
405
  path = dncli.credentials_path()
398
406
  assert stat.S_IMODE(path.stat().st_mode) == 0o600
399
407
  parser = dncli.load_credentials()
400
- assert parser["default"]["api_key"] == "dn_secretkey123456"
408
+ assert parser["default"]["api_key"] == VALID_KEY
401
409
  # the API URL is hard coded, never prompted for or stored
402
410
  assert "api_url" not in parser["default"]
403
411
 
404
412
 
413
+ @respx.mock
414
+ def test_configure_verifies_key_with_no_handles(isolated_config):
415
+ # an account with zero handles still returns 200 - a valid key, no warning
416
+ respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
417
+ result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
418
+ assert result.exit_code == 0
419
+ assert "rejected" not in all_output(result)
420
+ assert dncli.load_credentials()["default"]["api_key"] == VALID_KEY
421
+
422
+
423
+ @respx.mock
424
+ def test_configure_warns_when_api_rejects_key(isolated_config):
425
+ respx.get(HANDLES).mock(return_value=Response(401, json={"message": "no"}))
426
+ result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
427
+ assert result.exit_code == 0 # non-blocking: the key is still stored
428
+ assert "rejected it (401)" in all_output(result)
429
+ assert dncli.load_credentials()["default"]["api_key"] == VALID_KEY
430
+
431
+
432
+ @respx.mock
433
+ def test_configure_verify_ignores_network_errors(isolated_config):
434
+ respx.get(HANDLES).mock(side_effect=ConnectError("offline"))
435
+ result = runner.invoke(app, ["configure"], input=f"\n{VALID_KEY}\n")
436
+ assert result.exit_code == 0 # offline configure still succeeds
437
+ assert "rejected" not in all_output(result)
438
+ assert dncli.load_credentials()["default"]["api_key"] == VALID_KEY
439
+
440
+
441
+ @respx.mock
442
+ def test_configure_prompts_for_profile_name(isolated_config):
443
+ respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
444
+ # typing a name at the profile prompt overrides the "default" default
445
+ result = runner.invoke(app, ["configure"], input=f"acme\n{VALID_KEY}\n")
446
+ assert result.exit_code == 0
447
+ parser = dncli.load_credentials()
448
+ assert parser["acme"]["api_key"] == VALID_KEY
449
+ assert not parser.has_section("default")
450
+
451
+
452
+ @respx.mock
405
453
  def test_configure_named_profile(write_profile):
454
+ respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
406
455
  write_profile(api_key="existing")
407
456
  result = runner.invoke(
408
- app, ["configure", "--profile", "acme"], input="acme-key-9876543\n"
457
+ app, ["configure", "--profile", "acme"], input=f"\n{VALID_KEY}\n"
409
458
  )
410
459
  assert result.exit_code == 0
411
460
  parser = dncli.load_credentials()
412
- assert parser["acme"]["api_key"] == "acme-key-9876543"
461
+ assert parser["acme"]["api_key"] == VALID_KEY
462
+ assert parser["default"]["api_key"] == "existing" # untouched
463
+
464
+
465
+ @respx.mock
466
+ def test_configure_confirms_before_overwrite(write_profile):
467
+ respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
468
+ write_profile(api_key="existing")
469
+ result = runner.invoke(app, ["configure"], input=f"\ny\n{VALID_KEY}\n")
470
+ assert result.exit_code == 0
471
+ assert "already exists" in all_output(result)
472
+ parser = dncli.load_credentials()
473
+ assert parser["default"]["api_key"] == VALID_KEY
474
+
475
+
476
+ @respx.mock
477
+ def test_configure_warns_on_malformed_key(isolated_config):
478
+ respx.get(HANDLES).mock(return_value=Response(200, json={"data": []}))
479
+ # a key that fails the loose format check; "y" accepts it anyway
480
+ result = runner.invoke(app, ["configure"], input="\nnot-a-real-key\ny\n")
481
+ assert result.exit_code == 0
482
+ assert "does not look like" in all_output(result)
483
+ parser = dncli.load_credentials()
484
+ assert parser["default"]["api_key"] == "not-a-real-key"
485
+
486
+
487
+ def test_configure_malformed_key_declined_aborts(isolated_config):
488
+ result = runner.invoke(app, ["configure"], input="\nnot-a-real-key\nn\n")
489
+ assert result.exit_code == 1
490
+ assert not dncli.load_credentials().has_section("default")
491
+
492
+
493
+ def test_configure_abort_leaves_profile_untouched(write_profile):
494
+ write_profile(api_key="existing")
495
+ result = runner.invoke(app, ["configure"], input="\nn\n")
496
+ assert result.exit_code == 1
497
+ parser = dncli.load_credentials()
413
498
  assert parser["default"]["api_key"] == "existing" # untouched
414
499
 
415
500
 
@@ -419,7 +504,7 @@ def test_configure_transip_provider(isolated_config, tmp_path):
419
504
  result = runner.invoke(
420
505
  app,
421
506
  ["configure", "--provider", "transip", "--profile", "personal"],
422
- input=f"loek\n{key_file}\n",
507
+ input=f"\nloek\n{key_file}\n",
423
508
  )
424
509
  assert result.exit_code == 0
425
510
  parser = dncli.load_credentials()
@@ -437,7 +522,7 @@ def test_configure_rejects_non_pem_key(isolated_config, tmp_path):
437
522
  result = runner.invoke(
438
523
  app,
439
524
  ["configure", "--provider", "transip"],
440
- input=f"loek\n{bogus}\n",
525
+ input=f"\nloek\n{bogus}\n",
441
526
  )
442
527
  assert result.exit_code == 1
443
528
  assert "PEM" in all_output(result)
File without changes
File without changes
File without changes