evo-cli 0.31.0__tar.gz → 0.31.2__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 (154) hide show
  1. {evo_cli-0.31.0 → evo_cli-0.31.2}/PKG-INFO +1 -1
  2. evo_cli-0.31.2/evo_cli/VERSION +1 -0
  3. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/openvpn.py +12 -2
  4. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/update.py +162 -7
  5. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/openvpn.py +5 -0
  6. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli.egg-info/PKG-INFO +1 -1
  7. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_openvpn.py +44 -0
  8. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_update.py +127 -2
  9. evo_cli-0.31.0/evo_cli/VERSION +0 -1
  10. {evo_cli-0.31.0 → evo_cli-0.31.2}/Containerfile +0 -0
  11. {evo_cli-0.31.0 → evo_cli-0.31.2}/HISTORY.md +0 -0
  12. {evo_cli-0.31.0 → evo_cli-0.31.2}/LICENSE +0 -0
  13. {evo_cli-0.31.0 → evo_cli-0.31.2}/MANIFEST.in +0 -0
  14. {evo_cli-0.31.0 → evo_cli-0.31.2}/README.md +0 -0
  15. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/__init__.py +0 -0
  16. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/__main__.py +0 -0
  17. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/base.py +0 -0
  18. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/cli.py +0 -0
  19. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/__init__.py +0 -0
  20. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/agent_toy.py +0 -0
  21. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/claude_code.py +0 -0
  22. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/cloudflare.py +0 -0
  23. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/cred.py +0 -0
  24. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/download.py +0 -0
  25. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/fix_claude.py +0 -0
  26. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/gdrive.py +0 -0
  27. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/gh.py +0 -0
  28. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/__init__.py +0 -0
  29. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_dag.py +0 -0
  30. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_git.py +0 -0
  31. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_hub.py +0 -0
  32. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_mirror.py +0 -0
  33. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_model.py +0 -0
  34. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_mutate.py +0 -0
  35. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_paths.py +0 -0
  36. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_render.py +0 -0
  37. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/_server.py +0 -0
  38. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/check.py +0 -0
  39. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/clone.py +0 -0
  40. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/edit.py +0 -0
  41. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/pull.py +0 -0
  42. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/serve.py +0 -0
  43. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/show.py +0 -0
  44. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/views.py +0 -0
  45. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  46. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  47. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  48. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  49. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  50. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  51. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  52. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  53. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  54. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  55. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  56. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  57. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  58. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  59. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/harness/web/index.html +0 -0
  60. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/hwid.py +0 -0
  61. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/hwid_reset.py +0 -0
  62. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/image.py +0 -0
  63. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/localproxy.py +0 -0
  64. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/mcp.py +0 -0
  65. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/miniconda.py +0 -0
  66. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/netcheck.py +0 -0
  67. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/ngrok.py +0 -0
  68. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/opencode.py +0 -0
  69. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/plantuml.py +0 -0
  70. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/__init__.py +0 -0
  71. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/_dns.py +0 -0
  72. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/_dpi.py +0 -0
  73. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/_hosts.py +0 -0
  74. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/_probe.py +0 -0
  75. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/check.py +0 -0
  76. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/fix.py +0 -0
  77. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/probe.py +0 -0
  78. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/route/status.py +0 -0
  79. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/serp.py +0 -0
  80. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/site2s.py +0 -0
  81. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/ssh.py +0 -0
  82. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/storage.py +0 -0
  83. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/sysmon.py +0 -0
  84. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/tts.py +0 -0
  85. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/commands/wifi.py +0 -0
  86. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/console.py +0 -0
  87. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/__init__.py +0 -0
  88. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/doctor.py +0 -0
  89. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/google_oauth.py +0 -0
  90. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/migrate.py +0 -0
  91. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/oauth_flow.py +0 -0
  92. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/registry.py +0 -0
  93. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/store.py +0 -0
  94. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/credentials/sync.py +0 -0
  95. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/imaging/__init__.py +0 -0
  96. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/imaging/align.py +0 -0
  97. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/imaging/core.py +0 -0
  98. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/imaging/creds.py +0 -0
  99. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/imaging/errors.py +0 -0
  100. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/imaging/gemini.py +0 -0
  101. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/imaging/ncnn.py +0 -0
  102. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/mcp_registry.py +0 -0
  103. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/serp/__init__.py +0 -0
  104. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/serp/client.py +0 -0
  105. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/serp/creds.py +0 -0
  106. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/serp/errors.py +0 -0
  107. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/serp/install.py +0 -0
  108. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/serp/render.py +0 -0
  109. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/__init__.py +0 -0
  110. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/chunking.py +0 -0
  111. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/core.py +0 -0
  112. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/creds.py +0 -0
  113. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/errors.py +0 -0
  114. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/gemini.py +0 -0
  115. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/http.py +0 -0
  116. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/openai.py +0 -0
  117. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/player.py +0 -0
  118. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli/tts/vbee.py +0 -0
  119. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli.egg-info/SOURCES.txt +0 -0
  120. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli.egg-info/dependency_links.txt +0 -0
  121. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli.egg-info/entry_points.txt +0 -0
  122. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli.egg-info/requires.txt +0 -0
  123. {evo_cli-0.31.0 → evo_cli-0.31.2}/evo_cli.egg-info/top_level.txt +0 -0
  124. {evo_cli-0.31.0 → evo_cli-0.31.2}/pyproject.toml +0 -0
  125. {evo_cli-0.31.0 → evo_cli-0.31.2}/setup.cfg +0 -0
  126. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/__init__.py +0 -0
  127. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/fake_evo_agents.py +0 -0
  128. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  129. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  130. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_agent_toy.py +0 -0
  131. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_claude_code.py +0 -0
  132. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_cli.py +0 -0
  133. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_console.py +0 -0
  134. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_cred.py +0 -0
  135. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_download.py +0 -0
  136. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_fix_claude.py +0 -0
  137. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_gdrive.py +0 -0
  138. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_gh.py +0 -0
  139. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_harness.py +0 -0
  140. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_harness_clone.py +0 -0
  141. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_harness_dag.py +0 -0
  142. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_harness_hub.py +0 -0
  143. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_harness_show.py +0 -0
  144. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_image.py +0 -0
  145. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_mcp.py +0 -0
  146. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_ngrok.py +0 -0
  147. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_opencode.py +0 -0
  148. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_plantuml.py +0 -0
  149. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_route.py +0 -0
  150. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_serp.py +0 -0
  151. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_storage.py +0 -0
  152. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_sysmon.py +0 -0
  153. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_tts.py +0 -0
  154. {evo_cli-0.31.0 → evo_cli-0.31.2}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.31.0
3
+ Version: 0.31.2
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -0,0 +1 @@
1
+ 0.31.2
@@ -42,12 +42,22 @@ def _table(rows):
42
42
  _state_text(state["state"]),
43
43
  state.get("vpn_ip", ""),
44
44
  (profile.get("remotes") or [""])[0],
45
- "yes" if profile.get("password") else "no",
45
+ _creds(profile),
46
46
  "yes" if profile.get("totp") else "no",
47
47
  )
48
48
  return table
49
49
 
50
50
 
51
+ def _creds(profile):
52
+ """CREDS column: a certificate-only profile shows "cert", as it needs no username or password."""
53
+ if profile.get("password"):
54
+ return "yes"
55
+ try:
56
+ return "no" if vpn.uses_credentials(profile.get("config", "")) else "cert"
57
+ except vpn.CredentialError:
58
+ return "no"
59
+
60
+
51
61
  def _rows():
52
62
  profiles = _guard(vpn.profiles)
53
63
  return [(name, profiles[name], _guard(vpn.request, name)) for name in sorted(profiles)]
@@ -322,7 +332,7 @@ def connect(name, timeout, sudo_password_stdin, watch):
322
332
  _guard(vpn.validate_config, profile["config"])
323
333
  if not shutil.which("openvpn"):
324
334
  raise click.ClickException("Install OpenVPN first (macOS: brew install openvpn; Linux: your package manager).")
325
- if not profile.get("username") or not profile.get("password"):
335
+ if _guard(vpn.uses_credentials, profile["config"]) and not (profile.get("username") and profile.get("password")):
326
336
  raise click.ClickException("Missing credentials; run evo openvpn credentials PROFILE.")
327
337
  if _guard(vpn.request, name)["state"] != "STOPPED":
328
338
  raise click.ClickException("This profile already has a worker; use status or disconnect first.")
@@ -18,11 +18,16 @@ from evo_cli import __version__
18
18
  from evo_cli.console import CommandError, console, info, run_command, step, success, warning
19
19
 
20
20
  PACKAGE = "evo_cli"
21
+ TOOL_NAME = "evo-cli"
21
22
  PYPI_JSON = "https://pypi.org/pypi/{package}/json"
22
23
 
23
24
  VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)(?:[-_.]?(dev|alpha|a|beta|b|rc|c|pre)[-_.]?(\d*))?")
24
25
  PRE_RANK = {"dev": -4, "alpha": -3, "a": -3, "beta": -2, "b": -2, "pre": -1, "rc": -1, "c": -1}
25
26
 
27
+ EXACT_PIN_RE = re.compile(r"^===?\s*([^\s,*]+)$")
28
+ PLAIN_RECEIPT_KEYS = {"requirements", "python", "entrypoints", "options"}
29
+ PLAIN_REQUIREMENT_KEYS = {"name", "extras", "specifier"}
30
+
26
31
  EPILOG = Text.from_markup(
27
32
  "[bold]Examples[/bold]\n\n"
28
33
  " [cyan]evo update[/cyan] update to the newest release\n"
@@ -31,7 +36,9 @@ EPILOG = Text.from_markup(
31
36
  " [cyan]evo update --dry-run[/cyan] print the commands without running them\n"
32
37
  " [cyan]evo update --force[/cyan] reinstall even when already current\n\n"
33
38
  "[dim]An editable checkout is updated with git (fast-forward only, refusing to touch a\n"
34
- "dirty tree); a pipx, uv or pip install is upgraded with its own installer.[/dim]"
39
+ "dirty tree); a pipx, uv or pip install is upgraded with its own installer. A uv tool\n"
40
+ "pinned to an older ==version is reinstalled without the pin. The update succeeds only\n"
41
+ "when the installed version reaches the newest release afterwards.[/dim]"
35
42
  )
36
43
 
37
44
 
@@ -138,7 +145,79 @@ def report(install, current, latest):
138
145
  console.print()
139
146
 
140
147
 
141
- def build_commands(mode, allow_pre):
148
+ def load_toml(path):
149
+ try:
150
+ import tomllib
151
+ except ImportError: # Python 3.10
152
+ try:
153
+ import tomli as tomllib
154
+ except ImportError:
155
+ return {}
156
+ try:
157
+ with open(path, "rb") as handle:
158
+ return tomllib.load(handle)
159
+ except (OSError, ValueError):
160
+ return {}
161
+
162
+
163
+ def normalize_name(name):
164
+ return re.sub(r"[-_.]+", "-", str(name or "")).lower()
165
+
166
+
167
+ def uv_receipt(location):
168
+ """The `[tool]` table of a uv tool receipt and its evo-cli requirement (None when absent)."""
169
+ tool = load_toml(Path(location) / "uv-receipt.toml").get("tool") or {}
170
+ for requirement in tool.get("requirements") or []:
171
+ if isinstance(requirement, dict) and normalize_name(requirement.get("name")) == TOOL_NAME:
172
+ return tool, requirement
173
+ return tool, None
174
+
175
+
176
+ def exact_pin(requirement):
177
+ match = EXACT_PIN_RE.match(str((requirement or {}).get("specifier") or "").strip())
178
+ return match.group(1) if match else None
179
+
180
+
181
+ def requirement_text(requirement, specifier):
182
+ extras = requirement.get("extras") or []
183
+ name = requirement["name"] + (f"[{','.join(extras)}]" if extras else "")
184
+ return f"{name}{specifier}"
185
+
186
+
187
+ def uv_reinstall_command(location, latest, allow_pre):
188
+ """Reinstall a uv tool whose receipt pins an older `==` version, since `uv tool upgrade` keeps the pin.
189
+
190
+ Only a plain receipt is rebuilt: one evo-cli requirement from the index plus an optional Python
191
+ request. Anything more (`--with`, a git source, index options) goes through `uv tool upgrade`,
192
+ and the version check afterwards says what to run.
193
+ """
194
+ tool, requirement = uv_receipt(location)
195
+ pinned = exact_pin(requirement)
196
+ if latest is None or pinned is None or parse_version(pinned) >= parse_version(latest):
197
+ return None
198
+ if set(tool) - PLAIN_RECEIPT_KEYS or any((tool.get("options") or {}).values()):
199
+ return None
200
+ if len(tool.get("requirements") or []) != 1 or set(requirement) - PLAIN_REQUIREMENT_KEYS:
201
+ return None
202
+ info(f"The uv receipt pins [accent]{TOOL_NAME}=={pinned}[/accent], so the tool is reinstalled without the pin.")
203
+ cmd = [
204
+ "uv",
205
+ "tool",
206
+ "install",
207
+ "--force",
208
+ "--refresh-package",
209
+ requirement["name"],
210
+ requirement_text(requirement, f">={latest}"),
211
+ ]
212
+ if tool.get("python"):
213
+ cmd += ["--python", str(tool["python"])]
214
+ if allow_pre:
215
+ cmd.append("--prerelease=allow")
216
+ return cmd
217
+
218
+
219
+ def build_commands(install, latest, allow_pre):
220
+ mode = install["mode"]
142
221
  target = f"{PACKAGE}"
143
222
  if mode == "pipx":
144
223
  if shutil.which("pipx") is None:
@@ -150,6 +229,9 @@ def build_commands(mode, allow_pre):
150
229
  if mode == "uv":
151
230
  if shutil.which("uv") is None:
152
231
  raise click.ClickException("This looks like a uv tool install but uv is not on PATH.")
232
+ reinstall = uv_reinstall_command(install["location"], latest, allow_pre)
233
+ if reinstall:
234
+ return [reinstall]
153
235
  cmd = ["uv", "tool", "upgrade", target]
154
236
  if allow_pre:
155
237
  cmd.append("--prerelease=allow")
@@ -163,6 +245,80 @@ def build_commands(mode, allow_pre):
163
245
  return [cmd]
164
246
 
165
247
 
248
+ def installed_version():
249
+ """The evo version the install holds now, read by a new interpreter: this process keeps the old one.
250
+
251
+ The probe drops the current directory ("") from sys.path so that a checkout's egg-info cannot answer.
252
+ """
253
+ code = (
254
+ "import sys; sys.path = [p for p in sys.path if p]; "
255
+ f"from importlib.metadata import version; print(version({PACKAGE!r}))"
256
+ )
257
+ probe = subprocess.run(
258
+ [sys.executable, "-c", code],
259
+ capture_output=True,
260
+ text=True,
261
+ encoding="utf-8",
262
+ errors="replace",
263
+ check=False,
264
+ )
265
+ version = (probe.stdout or "").strip()
266
+ return version if probe.returncode == 0 and version else None
267
+
268
+
269
+ def pipx_spec(location):
270
+ try:
271
+ data = json.loads((Path(location) / "pipx_metadata.json").read_text(encoding="utf-8"))
272
+ except (OSError, ValueError):
273
+ return None
274
+ return (data.get("main_package") or {}).get("package_or_url")
275
+
276
+
277
+ def stale_hint(install, latest):
278
+ """Why the installer stopped short of `latest`, and the command that gets there."""
279
+ location = install["location"]
280
+ if install["mode"] == "uv":
281
+ _, requirement = uv_receipt(location)
282
+ specifier = (requirement or {}).get("specifier")
283
+ fix = f"uv tool install --force --refresh-package {TOOL_NAME} '{TOOL_NAME}>={latest}'"
284
+ if specifier:
285
+ receipt = Path(location) / "uv-receipt.toml"
286
+ return (
287
+ f"{receipt} pins {TOOL_NAME}{specifier}, which `uv tool upgrade` keeps. "
288
+ f"Reinstall without the pin:\n {fix}"
289
+ )
290
+ return f"uv may have read a cached package index from before the release. Refresh it:\n {fix}"
291
+ if install["mode"] == "pipx":
292
+ fix = f"pipx install --force '{TOOL_NAME}>={latest}'"
293
+ spec = pipx_spec(location)
294
+ if spec and re.search(r"[=<>~!]", spec):
295
+ return f"pipx installed evo as {spec}, which `pipx upgrade` keeps. Reinstall without the pin:\n {fix}"
296
+ return f"Reinstall it:\n {fix}"
297
+ return (
298
+ "A pip constraint or a cached index may hold it back. Run:\n"
299
+ f" {sys.executable} -m pip install --upgrade '{TOOL_NAME}>={latest}'"
300
+ )
301
+
302
+
303
+ def confirm_update(install, current, latest):
304
+ """Report success only for the version that is really installed now."""
305
+ installed = installed_version()
306
+ if installed is None:
307
+ raise click.ClickException("The installer finished, but the installed evo version cannot be read.")
308
+ if latest is None:
309
+ if installed == current:
310
+ warning(f"evo is still at [accent]{installed}[/accent], and PyPI did not say whether a newer one exists.")
311
+ else:
312
+ success(f"evo is now at [accent]{installed}[/accent].")
313
+ return
314
+ if parse_version(installed) < parse_version(latest):
315
+ raise click.ClickException(
316
+ f"evo is still at {installed}, not {latest}, after the update. {stale_hint(install, latest)}"
317
+ )
318
+ success(f"evo updated to [accent]{installed}[/accent].")
319
+ info("Open a new shell if the `evo` command still reports the old version.")
320
+
321
+
166
322
  def execute(commands, dry_run):
167
323
  for cmd in commands:
168
324
  if dry_run:
@@ -238,7 +394,7 @@ def update_editable(path, check, force, dry_run):
238
394
  success(f"evo is now at [accent]{version_file.read_text(encoding='utf-8').strip()}[/accent]")
239
395
 
240
396
 
241
- def update_package(mode, current, latest, check, allow_pre, force, dry_run):
397
+ def update_package(install, current, latest, check, allow_pre, force, dry_run):
242
398
  if latest is None:
243
399
  if check:
244
400
  raise click.ClickException("Cannot determine the latest version from PyPI.")
@@ -253,10 +409,9 @@ def update_package(mode, current, latest, check, allow_pre, force, dry_run):
253
409
  info("Run [accent]evo update[/accent] to install it.")
254
410
  return
255
411
 
256
- execute(build_commands(mode, allow_pre), dry_run)
412
+ execute(build_commands(install, latest, allow_pre), dry_run)
257
413
  if not dry_run:
258
- success(f"evo updated to [accent]{latest or 'the latest release'}[/accent].")
259
- info("Open a new shell if the `evo` command still reports the old version.")
414
+ confirm_update(install, current, latest)
260
415
 
261
416
 
262
417
  @click.command("update", epilog=EPILOG, context_settings={"help_option_names": ["-h", "--help"]})
@@ -288,4 +443,4 @@ def update(check, allow_pre, force, dry_run, timeout):
288
443
  if install["mode"] == "editable":
289
444
  update_editable(install["location"], check, force, dry_run)
290
445
  return
291
- update_package(install["mode"], __version__, latest, check, allow_pre, force, dry_run)
446
+ update_package(install, __version__, latest, check, allow_pre, force, dry_run)
@@ -178,6 +178,11 @@ def validate_config(text):
178
178
  return remotes
179
179
 
180
180
 
181
+ def uses_credentials(text):
182
+ """Whether OpenVPN will ask for a username and password; a certificate-only profile never does."""
183
+ return any(words[0].removeprefix("--") == "auth-user-pass" for _, words in _directives(text))
184
+
185
+
181
186
  def takes_default_route(text):
182
187
  """Whether a profile may take the default route. Only split-tunnel profiles can run beside another VPN.
183
188
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.31.0
3
+ Version: 0.31.2
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -126,6 +126,21 @@ def test_split_tunnel_is_only_what_provably_keeps_the_default_route(extra, full)
126
126
  assert vpn.takes_default_route(CONFIG + extra) is full
127
127
 
128
128
 
129
+ CERT_ONLY = CONFIG.replace("auth-user-pass\n", "")
130
+
131
+
132
+ @pytest.mark.parametrize(
133
+ "config,expected",
134
+ [
135
+ (CONFIG, True),
136
+ (CERT_ONLY, False),
137
+ (CERT_ONLY + "<key>\nauth-user-pass\n</key>\n", False),
138
+ ],
139
+ )
140
+ def test_only_auth_user_pass_asks_for_credentials(config, expected):
141
+ assert vpn.uses_credentials(config) is expected
142
+
143
+
129
144
  @pytest.mark.parametrize(
130
145
  "platform,output,gateway",
131
146
  [
@@ -450,6 +465,35 @@ def test_connect_stacks_a_split_tunnel_beside_a_full_one_only(store, monkeypatch
450
465
  assert "default route" not in result.output and started, result.output
451
466
 
452
467
 
468
+ def test_cert_only_profile_connects_and_lists_without_credentials(store, monkeypatch):
469
+ vpn.save_profile("cert", {"config": CERT_ONLY})
470
+ vpn.save_profile("login", {"config": CONFIG})
471
+ monkeypatch.setattr(vpn, "request", lambda name, command="status": {"state": "STOPPED"})
472
+ monkeypatch.setattr("evo_cli.commands.openvpn.shutil.which", lambda _: "/fake/openvpn")
473
+ monkeypatch.setattr("evo_cli.commands.openvpn.os.geteuid", lambda: 0)
474
+ started = []
475
+
476
+ class Exited:
477
+ def __init__(self):
478
+ self.stdin = io.BytesIO()
479
+
480
+ def poll(self):
481
+ return 1
482
+
483
+ monkeypatch.setattr(
484
+ "evo_cli.commands.openvpn.subprocess.Popen", lambda command, **_: started.append(command) or Exited()
485
+ )
486
+ runner = CliRunner()
487
+ result = runner.invoke(cli, ["openvpn", "connect", "login", "--no-watch"])
488
+ assert result.exit_code != 0 and "Missing credentials" in result.output and not started
489
+ result = runner.invoke(cli, ["openvpn", "connect", "cert", "--no-watch"])
490
+ assert "Missing credentials" not in result.output and started, result.output
491
+ rows = {
492
+ line.split()[1]: line.split()[-2] for line in runner.invoke(cli, ["openvpn", "list"]).output.splitlines()[1:3]
493
+ }
494
+ assert rows == {"cert": "cert", "login": "no"}
495
+
496
+
453
497
  def test_pick_profile_by_number_single_or_prompt(store, monkeypatch):
454
498
  runner = CliRunner()
455
499
  totp = vpn.parse_totp(SECRET)
@@ -1,4 +1,5 @@
1
1
  import subprocess
2
+ import sys
2
3
 
3
4
  from click.testing import CliRunner
4
5
 
@@ -18,6 +19,29 @@ def _stub_pypi(monkeypatch, versions):
18
19
  monkeypatch.setattr(update_command, "fetch_pypi", lambda package, timeout: _pypi(versions))
19
20
 
20
21
 
22
+ def _stub_installer(monkeypatch, installed_after):
23
+ """Record the installer commands; the version probe then reports `installed_after`."""
24
+ ran = []
25
+ monkeypatch.setattr(update_command, "run_command", lambda cmd: ran.append([str(part) for part in cmd]))
26
+
27
+ def fake_run(cmd, **kwargs):
28
+ assert cmd[0] == sys.executable
29
+ return _result(stdout=f"{installed_after}\n")
30
+
31
+ monkeypatch.setattr(update_command.subprocess, "run", fake_run)
32
+ return ran
33
+
34
+
35
+ def _uv_install(monkeypatch, tmp_path, receipt):
36
+ (tmp_path / "uv-receipt.toml").write_text(receipt, encoding="utf-8")
37
+ monkeypatch.setattr(update_command, "detect_install", lambda: {"mode": "uv", "location": tmp_path})
38
+ monkeypatch.setattr(update_command.shutil, "which", lambda name: f"/usr/bin/{name}")
39
+
40
+
41
+ def _flat(output):
42
+ return " ".join(output.replace("│", " ").split())
43
+
44
+
21
45
  def test_update_command_is_registered():
22
46
  assert "update" in cli.commands
23
47
 
@@ -76,14 +100,113 @@ def test_update_runs_pip_upgrade_when_outdated(monkeypatch):
76
100
  _stub_pypi(monkeypatch, ["0.11.3", "0.12.0"])
77
101
  monkeypatch.setattr(update_command, "detect_install", lambda: {"mode": "pip", "location": "/venv"})
78
102
  monkeypatch.setattr(update_command, "__version__", "0.11.3")
79
- monkeypatch.setattr(update_command, "build_commands", lambda mode, allow_pre: [["pip", "install", "-U", mode]])
103
+ monkeypatch.setattr(
104
+ update_command, "build_commands", lambda install, latest, allow_pre: [["pip", "install", "-U", install["mode"]]]
105
+ )
80
106
  calls = []
81
107
  monkeypatch.setattr(update_command, "execute", lambda commands, dry_run: calls.append((commands, dry_run)))
108
+ _stub_installer(monkeypatch, "0.12.0")
82
109
 
83
110
  result = CliRunner().invoke(cli, ["update"])
84
111
 
85
112
  assert result.exit_code == 0
86
113
  assert calls == [([["pip", "install", "-U", "pip"]], False)]
114
+ assert "evo updated to 0.12.0" in result.output
115
+
116
+
117
+ def test_update_fails_when_the_upgrade_leaves_the_old_version(monkeypatch):
118
+ _stub_pypi(monkeypatch, ["0.11.3", "0.12.0"])
119
+ monkeypatch.setattr(update_command, "detect_install", lambda: {"mode": "pip", "location": "/venv"})
120
+ monkeypatch.setattr(update_command, "__version__", "0.11.3")
121
+ ran = _stub_installer(monkeypatch, "0.11.3")
122
+
123
+ result = CliRunner().invoke(cli, ["update"])
124
+
125
+ assert result.exit_code == 1
126
+ assert ran == [[sys.executable, "-m", "pip", "install", "--upgrade", "evo_cli"]]
127
+ assert "evo updated" not in result.output
128
+ assert "evo is still at 0.11.3, not 0.12.0" in _flat(result.output)
129
+
130
+
131
+ def test_uv_update_reinstalls_over_a_stale_exact_pin(monkeypatch, tmp_path):
132
+ _stub_pypi(monkeypatch, ["0.31.0", "0.31.1"])
133
+ monkeypatch.setattr(update_command, "__version__", "0.31.0")
134
+ _uv_install(
135
+ monkeypatch,
136
+ tmp_path,
137
+ '[tool]\nrequirements = [{ name = "evo-cli", specifier = "==0.31.0" }]\n'
138
+ 'entrypoints = [{ name = "evo", install-path = "/home/me/.local/bin/evo" }]\n',
139
+ )
140
+ ran = _stub_installer(monkeypatch, "0.31.1")
141
+
142
+ result = CliRunner().invoke(cli, ["update"])
143
+
144
+ assert result.exit_code == 0, result.output
145
+ assert ran == [["uv", "tool", "install", "--force", "--refresh-package", "evo-cli", "evo-cli>=0.31.1"]]
146
+ assert "pins evo-cli==0.31.0" in _flat(result.output)
147
+ assert "evo updated to 0.31.1" in result.output
148
+
149
+
150
+ def test_uv_reinstall_keeps_extras_and_python(monkeypatch, tmp_path):
151
+ _stub_pypi(monkeypatch, ["0.31.0", "0.31.1"])
152
+ monkeypatch.setattr(update_command, "__version__", "0.31.0")
153
+ _uv_install(
154
+ monkeypatch,
155
+ tmp_path,
156
+ '[tool]\nrequirements = [{ name = "evo-cli", extras = ["image"], specifier = "==0.31.0" }]\npython = "3.13"\n',
157
+ )
158
+ ran = _stub_installer(monkeypatch, "0.31.1")
159
+
160
+ result = CliRunner().invoke(cli, ["update"])
161
+
162
+ assert result.exit_code == 0, result.output
163
+ assert ran == [
164
+ [
165
+ "uv",
166
+ "tool",
167
+ "install",
168
+ "--force",
169
+ "--refresh-package",
170
+ "evo-cli",
171
+ "evo-cli[image]>=0.31.1",
172
+ "--python",
173
+ "3.13",
174
+ ]
175
+ ]
176
+
177
+
178
+ def test_uv_upgrade_held_by_a_pin_it_cannot_rebuild_fails_with_the_fix(monkeypatch, tmp_path):
179
+ _stub_pypi(monkeypatch, ["0.31.0", "0.31.1"])
180
+ monkeypatch.setattr(update_command, "__version__", "0.31.0")
181
+ _uv_install(
182
+ monkeypatch,
183
+ tmp_path,
184
+ '[tool]\nrequirements = [{ name = "evo-cli", specifier = "==0.31.0" }, { name = "rich" }]\n',
185
+ )
186
+ ran = _stub_installer(monkeypatch, "0.31.0")
187
+
188
+ result = CliRunner().invoke(cli, ["update"])
189
+ output = _flat(result.output)
190
+
191
+ assert result.exit_code == 1
192
+ assert ran == [["uv", "tool", "upgrade", "evo_cli"]]
193
+ assert "evo updated" not in result.output
194
+ assert "evo is still at 0.31.0, not 0.31.1" in output
195
+ assert "pins evo-cli==0.31.0" in output
196
+ assert "uv tool install --force --refresh-package evo-cli 'evo-cli>=0.31.1'" in output
197
+
198
+
199
+ def test_uv_upgrade_without_a_pin_skips_the_reinstall(monkeypatch, tmp_path):
200
+ _stub_pypi(monkeypatch, ["0.31.0", "0.31.1"])
201
+ monkeypatch.setattr(update_command, "__version__", "0.31.0")
202
+ _uv_install(monkeypatch, tmp_path, '[tool]\nrequirements = [{ name = "evo-cli" }]\n')
203
+ ran = _stub_installer(monkeypatch, "0.31.1")
204
+
205
+ result = CliRunner().invoke(cli, ["update"])
206
+
207
+ assert result.exit_code == 0, result.output
208
+ assert ran == [["uv", "tool", "upgrade", "evo_cli"]]
209
+ assert "evo updated to 0.31.1" in result.output
87
210
 
88
211
 
89
212
  def test_editable_update_pulls_and_reports_head(monkeypatch, tmp_path):
@@ -167,7 +290,9 @@ def test_dry_run_prints_commands_without_running_them(monkeypatch):
167
290
  _stub_pypi(monkeypatch, ["0.11.3", "0.12.0"])
168
291
  monkeypatch.setattr(update_command, "detect_install", lambda: {"mode": "pip", "location": "/venv"})
169
292
  monkeypatch.setattr(update_command, "__version__", "0.11.3")
170
- monkeypatch.setattr(update_command, "build_commands", lambda mode, allow_pre: [["pip", "install", "-U", "evo_cli"]])
293
+ monkeypatch.setattr(
294
+ update_command, "build_commands", lambda install, latest, allow_pre: [["pip", "install", "-U", "evo_cli"]]
295
+ )
171
296
  monkeypatch.setattr(update_command, "run_command", lambda cmd: (_ for _ in ()).throw(AssertionError("ran")))
172
297
 
173
298
  result = CliRunner().invoke(cli, ["update", "--dry-run"])
@@ -1 +0,0 @@
1
- 0.31.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes