evo-cli 0.11.0__tar.gz → 0.11.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 (53) hide show
  1. {evo_cli-0.11.0 → evo_cli-0.11.2}/PKG-INFO +1 -1
  2. evo_cli-0.11.2/evo_cli/VERSION +1 -0
  3. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/cli.py +4 -0
  4. evo_cli-0.11.2/evo_cli/commands/gh.py +420 -0
  5. evo_cli-0.11.2/evo_cli/commands/hwid.py +237 -0
  6. evo_cli-0.11.2/evo_cli/commands/hwid_reset.py +197 -0
  7. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/console.py +5 -1
  8. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/PKG-INFO +1 -1
  9. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/SOURCES.txt +2 -0
  10. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_console.py +13 -1
  11. evo_cli-0.11.2/tests/test_gh.py +285 -0
  12. evo_cli-0.11.0/evo_cli/VERSION +0 -1
  13. evo_cli-0.11.0/evo_cli/commands/gh.py +0 -246
  14. evo_cli-0.11.0/tests/test_gh.py +0 -146
  15. {evo_cli-0.11.0 → evo_cli-0.11.2}/Containerfile +0 -0
  16. {evo_cli-0.11.0 → evo_cli-0.11.2}/HISTORY.md +0 -0
  17. {evo_cli-0.11.0 → evo_cli-0.11.2}/LICENSE +0 -0
  18. {evo_cli-0.11.0 → evo_cli-0.11.2}/MANIFEST.in +0 -0
  19. {evo_cli-0.11.0 → evo_cli-0.11.2}/README.md +0 -0
  20. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/__init__.py +0 -0
  21. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/__main__.py +0 -0
  22. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/base.py +0 -0
  23. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/__init__.py +0 -0
  24. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/claude_code.py +0 -0
  25. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/cloudflare.py +0 -0
  26. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/fix_claude.py +0 -0
  27. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/gdrive.py +0 -0
  28. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/localproxy.py +0 -0
  29. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/mcp.py +0 -0
  30. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/miniconda.py +0 -0
  31. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/netcheck.py +0 -0
  32. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/opencode.py +0 -0
  33. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/plantuml.py +0 -0
  34. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/site2s.py +0 -0
  35. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/ssh.py +0 -0
  36. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/sysmon.py +0 -0
  37. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/wifi.py +0 -0
  38. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/mcp_registry.py +0 -0
  39. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/dependency_links.txt +0 -0
  40. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/entry_points.txt +0 -0
  41. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/requires.txt +0 -0
  42. {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/top_level.txt +0 -0
  43. {evo_cli-0.11.0 → evo_cli-0.11.2}/pyproject.toml +0 -0
  44. {evo_cli-0.11.0 → evo_cli-0.11.2}/setup.cfg +0 -0
  45. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/__init__.py +0 -0
  46. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_claude_code.py +0 -0
  47. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_cli.py +0 -0
  48. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_fix_claude.py +0 -0
  49. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_mcp.py +0 -0
  50. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_opencode.py +0 -0
  51. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_plantuml.py +0 -0
  52. {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_sysmon.py +0 -0
  53. {evo_cli-0.11.0 → evo_cli-0.11.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.11.0
3
+ Version: 0.11.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.11.2
@@ -6,6 +6,8 @@ from evo_cli.commands.cloudflare import cfssh
6
6
  from evo_cli.commands.fix_claude import f_claude
7
7
  from evo_cli.commands.gdrive import gdrive
8
8
  from evo_cli.commands.gh import setup_gh
9
+ from evo_cli.commands.hwid import hwid
10
+ from evo_cli.commands.hwid_reset import hwid_reset
9
11
  from evo_cli.commands.localproxy import localproxy
10
12
  from evo_cli.commands.mcp import mcp_group
11
13
  from evo_cli.commands.miniconda import miniconda
@@ -54,6 +56,8 @@ cli.add_command(setup_group)
54
56
  cli.add_command(cfssh)
55
57
  cli.add_command(f_claude)
56
58
  cli.add_command(gdrive)
59
+ cli.add_command(hwid)
60
+ cli.add_command(hwid_reset)
57
61
  cli.add_command(site2s)
58
62
  cli.add_command(localproxy)
59
63
  cli.add_command(mcp_group)
@@ -0,0 +1,420 @@
1
+ """Install the GitHub CLI (gh) and report whether it is authenticated."""
2
+
3
+ import os
4
+ import platform
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import tarfile
9
+ import tempfile
10
+ import urllib.request
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ import rich_click as click
15
+ from rich.panel import Panel
16
+ from rich.text import Text
17
+
18
+ from evo_cli.console import (
19
+ CommandError,
20
+ console,
21
+ download_file,
22
+ error,
23
+ info,
24
+ resolve_executable,
25
+ run_command,
26
+ step,
27
+ success,
28
+ warning,
29
+ )
30
+
31
+ EPILOG = Text.from_markup(
32
+ "[bold]Examples[/bold]\n\n"
33
+ " [cyan]evo setup gh[/cyan] install gh, then check its auth status\n"
34
+ " [cyan]evo setup gh --reinstall[/cyan] install again even if gh is present\n"
35
+ " [cyan]evo setup gh --user[/cyan] install into ~/.local/bin; never touch the system\n"
36
+ " [cyan]evo setup gh --skip-auth-check[/cyan] install only; do not run `gh auth status`"
37
+ )
38
+
39
+ RELEASES_URL = "https://github.com/cli/cli/releases/latest"
40
+ DOWNLOAD_URL = "https://github.com/cli/cli/releases/download"
41
+
42
+ # Where a userspace install puts the binary. Not on PATH in every shell, so
43
+ # callers have to check.
44
+ USER_BIN = Path.home() / ".local" / "bin"
45
+
46
+ # uname machine -> the arch slug used in gh's release asset names.
47
+ ARCH_SLUGS = {
48
+ "x86_64": "amd64",
49
+ "amd64": "amd64",
50
+ "aarch64": "arm64",
51
+ "arm64": "arm64",
52
+ "armv6l": "armv6",
53
+ "armv7l": "armv6",
54
+ "i386": "386",
55
+ "i686": "386",
56
+ }
57
+
58
+ # gh is not in the default Debian/Ubuntu repositories, so the official
59
+ # instructions add GitHub's keyring and apt source first. Kept close to upstream:
60
+ # https://github.com/cli/cli/blob/trunk/docs/install_linux.md
61
+ APT_SCRIPT = """set -e
62
+ (type -p wget >/dev/null || ($SUDO apt update && $SUDO apt install wget -y))
63
+ $SUDO mkdir -p -m 755 /etc/apt/keyrings
64
+ out=$(mktemp)
65
+ wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg
66
+ cat $out | $SUDO tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null
67
+ $SUDO chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
68
+ $SUDO mkdir -p -m 755 /etc/apt/sources.list.d
69
+ echo "deb [arch=$(dpkg --print-architecture) \
70
+ signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] \
71
+ https://cli.github.com/packages stable main" | $SUDO tee /etc/apt/sources.list.d/github-cli.list > /dev/null
72
+ $SUDO apt update
73
+ $SUDO apt install gh -y
74
+ """
75
+
76
+ # Fedora ships gh in its own repositories; only fall back to GitHub's rpm repo
77
+ # when the distro does not carry the package.
78
+ DNF_SCRIPT = """$SUDO dnf install -y gh || {
79
+ $SUDO dnf install -y dnf5-plugins
80
+ $SUDO dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo
81
+ $SUDO dnf install -y gh
82
+ }
83
+ """
84
+
85
+ ZYPPER_SCRIPT = """set -e
86
+ $SUDO zypper addrepo https://cli.github.com/packages/rpm/gh-cli.repo
87
+ $SUDO zypper ref
88
+ $SUDO zypper install -y gh
89
+ """
90
+
91
+
92
+ def is_root():
93
+ geteuid = getattr(os, "geteuid", None)
94
+ return geteuid is not None and geteuid() == 0
95
+
96
+
97
+ def can_elevate():
98
+ """Whether privileged package-manager commands are possible at all.
99
+
100
+ Containers commonly run as an unprivileged user with no sudo installed (the
101
+ Jupyter `jovyan` image, for one), so every system-wide install path is out
102
+ and only a userspace install can work.
103
+ """
104
+ return is_root() or bool(shutil.which("sudo"))
105
+
106
+
107
+ def shell_script(script):
108
+ """Wrap a package-manager script, defining $SUDO to '' when already root."""
109
+ sudo = "" if is_root() else "sudo"
110
+ return ["bash", "-c", f"SUDO={sudo}\n{script}"]
111
+
112
+
113
+ def elevated(argv):
114
+ """Prefix a command with sudo unless we are already root."""
115
+ return list(argv) if is_root() else ["sudo", *argv]
116
+
117
+
118
+ def gh_version():
119
+ """Return the installed gh version string, or None if gh is not on PATH."""
120
+ if not shutil.which("gh"):
121
+ return None
122
+ try:
123
+ result = subprocess.run(
124
+ resolve_executable(["gh", "--version"]),
125
+ capture_output=True,
126
+ text=True,
127
+ encoding="utf-8",
128
+ errors="replace",
129
+ timeout=30,
130
+ )
131
+ except (OSError, subprocess.SubprocessError):
132
+ return "unknown"
133
+ match = re.search(r"\d+\.\d+\.\d+", result.stdout or "")
134
+ return match.group(0) if match else "unknown"
135
+
136
+
137
+ def linux_install_command():
138
+ if shutil.which("apt-get"):
139
+ return shell_script(APT_SCRIPT)
140
+ if shutil.which("dnf"):
141
+ return shell_script(DNF_SCRIPT)
142
+ if shutil.which("pacman"):
143
+ return elevated(["pacman", "-S", "--noconfirm", "github-cli"])
144
+ if shutil.which("apk"):
145
+ return elevated(["apk", "add", "github-cli"])
146
+ if shutil.which("zypper"):
147
+ return shell_script(ZYPPER_SCRIPT)
148
+ return None
149
+
150
+
151
+ def windows_install_command():
152
+ if shutil.which("winget"):
153
+ # The accept flags keep winget from stopping on an interactive agreement prompt.
154
+ return [
155
+ "winget",
156
+ "install",
157
+ "--id",
158
+ "GitHub.cli",
159
+ "--source",
160
+ "winget",
161
+ "--accept-package-agreements",
162
+ "--accept-source-agreements",
163
+ ]
164
+ if shutil.which("scoop"):
165
+ return ["scoop", "install", "gh"]
166
+ if shutil.which("choco"):
167
+ return ["choco", "install", "gh", "-y"]
168
+ return None
169
+
170
+
171
+ def install_command():
172
+ """The system-wide gh install command for this platform, or None if unusable."""
173
+ system = platform.system()
174
+ if system == "Darwin":
175
+ return ["brew", "install", "gh"] if shutil.which("brew") else None
176
+ if system == "Linux":
177
+ return linux_install_command() if can_elevate() else None
178
+ if system == "Windows":
179
+ return windows_install_command()
180
+ return None
181
+
182
+
183
+ def latest_gh_version():
184
+ """Resolve the latest gh version from the releases redirect.
185
+
186
+ /releases/latest redirects to /releases/tag/vX.Y.Z, so the version can be
187
+ read without spending an unauthenticated GitHub API call (60/hour per IP).
188
+ """
189
+ request = urllib.request.Request(RELEASES_URL, headers={"User-Agent": "evo-cli"})
190
+ try:
191
+ with urllib.request.urlopen(request, timeout=30) as response:
192
+ final_url = response.geturl()
193
+ except OSError as exc:
194
+ warning(f"Could not reach {RELEASES_URL}: {exc}")
195
+ return None
196
+ match = re.search(r"/tag/v?(\d+\.\d+\.\d+)", final_url or "")
197
+ return match.group(1) if match else None
198
+
199
+
200
+ def release_arch():
201
+ return ARCH_SLUGS.get(platform.machine().lower())
202
+
203
+
204
+ def asset_url(version, arch, system):
205
+ """URL of the official release archive for this platform."""
206
+ if system == "Darwin":
207
+ return f"{DOWNLOAD_URL}/v{version}/gh_{version}_macOS_{arch}.zip"
208
+ return f"{DOWNLOAD_URL}/v{version}/gh_{version}_linux_{arch}.tar.gz"
209
+
210
+
211
+ def extract_gh_binary(archive, destination):
212
+ """Pull just bin/gh out of the release archive and write it to destination.
213
+
214
+ Only the one member is read, so a hostile archive cannot write outside the
215
+ destination the way a blanket extractall() could.
216
+ """
217
+ if str(archive).endswith(".zip"):
218
+ with zipfile.ZipFile(archive) as bundle:
219
+ names = [n for n in bundle.namelist() if n.endswith("bin/gh")]
220
+ if not names:
221
+ return False
222
+ with bundle.open(names[0]) as source, open(destination, "wb") as target:
223
+ shutil.copyfileobj(source, target)
224
+ else:
225
+ with tarfile.open(archive) as bundle:
226
+ members = [m for m in bundle.getmembers() if m.name.endswith("bin/gh") and m.isfile()]
227
+ if not members:
228
+ return False
229
+ source = bundle.extractfile(members[0])
230
+ if source is None:
231
+ return False
232
+ with source, open(destination, "wb") as target:
233
+ shutil.copyfileobj(source, target)
234
+
235
+ destination.chmod(0o755)
236
+ return True
237
+
238
+
239
+ def ensure_user_bin_on_path():
240
+ """Make a just-installed ~/.local/bin/gh visible to this process.
241
+
242
+ The user's shell has not re-read PATH yet, so without this the auth check
243
+ below would not find the binary we just wrote.
244
+ """
245
+ entries = os.environ.get("PATH", "").split(os.pathsep)
246
+ if str(USER_BIN) in entries:
247
+ return True
248
+ os.environ["PATH"] = f"{USER_BIN}{os.pathsep}{os.environ.get('PATH', '')}"
249
+ return False
250
+
251
+
252
+ def install_from_release():
253
+ """Install gh into ~/.local/bin straight from the official release archive.
254
+
255
+ This is the path for machines where a system-wide install is impossible: no
256
+ root, no sudo, no usable package manager. It needs no privileges because it
257
+ only writes under the user's home.
258
+ """
259
+ step("Installing gh from the official release")
260
+ system = platform.system()
261
+ if system not in ("Linux", "Darwin"):
262
+ error(f"No userspace install available for {system}.")
263
+ return False
264
+
265
+ arch = release_arch()
266
+ if not arch:
267
+ error(f"Unsupported architecture: {platform.machine()}")
268
+ return False
269
+
270
+ version = latest_gh_version()
271
+ if not version:
272
+ error("Could not work out the latest gh version.")
273
+ return False
274
+
275
+ url = asset_url(version, arch, system)
276
+ info(f"Downloading gh {version} ({system.lower()}/{arch})")
277
+ USER_BIN.mkdir(parents=True, exist_ok=True)
278
+
279
+ with tempfile.TemporaryDirectory() as tmpdir:
280
+ archive = Path(tmpdir) / url.rsplit("/", 1)[-1]
281
+ try:
282
+ download_file(url, archive, description=f"gh {version}")
283
+ except OSError as exc:
284
+ error(f"Download failed: {exc}")
285
+ return False
286
+
287
+ if not extract_gh_binary(archive, USER_BIN / "gh"):
288
+ error(f"No gh binary inside {archive.name}")
289
+ return False
290
+
291
+ already_on_path = ensure_user_bin_on_path()
292
+ success(f"gh {version} installed to [accent]{USER_BIN / 'gh'}[/accent]")
293
+ if not already_on_path:
294
+ warning(f"{USER_BIN} is not on your PATH")
295
+ info('Add this to your shell rc: [accent]export PATH="$HOME/.local/bin:$PATH"[/accent]')
296
+ return True
297
+
298
+
299
+ def install_gh(reinstall=False, user=False):
300
+ """Install the GitHub CLI. Returns True when `gh` ends up runnable."""
301
+ step("Installing GitHub CLI")
302
+
303
+ version = gh_version()
304
+ if version and not reinstall:
305
+ info(f"gh already installed ({version}); skipping install")
306
+ return True
307
+
308
+ if user:
309
+ info("Installing into ~/.local/bin as requested")
310
+ return install_from_release()
311
+
312
+ command = install_command()
313
+ if command is None:
314
+ if platform.system() == "Linux" and not can_elevate():
315
+ warning("No root and no sudo, so a system-wide install is not possible")
316
+ else:
317
+ warning("No usable package manager found")
318
+ info("Falling back to a userspace install in ~/.local/bin")
319
+ return install_from_release()
320
+
321
+ try:
322
+ run_command(command, status="Installing gh", stdin=subprocess.DEVNULL, timeout=900)
323
+ except CommandError:
324
+ warning("The package-manager install failed; falling back to ~/.local/bin")
325
+ return install_from_release()
326
+
327
+ if not gh_version():
328
+ warning("gh installed but it is not on PATH yet")
329
+ info("Open a new terminal, then run `gh --version` to confirm.")
330
+ return False
331
+
332
+ success(f"gh installed ({gh_version()})")
333
+ return True
334
+
335
+
336
+ def check_gh_auth():
337
+ """Report whether gh holds GitHub credentials.
338
+
339
+ `gh auth status` is read-only and exits non-zero when logged out, so it is
340
+ safe here. `gh auth login` is not: it needs a TTY and a browser, so leave it
341
+ to the user rather than blocking the run on it.
342
+ """
343
+ step("Checking GitHub authentication")
344
+ result = run_command(
345
+ ["gh", "auth", "status"],
346
+ status="Running gh auth status",
347
+ stdin=subprocess.DEVNULL,
348
+ timeout=60,
349
+ check=False,
350
+ )
351
+ if getattr(result, "returncode", None) == 0:
352
+ success("gh is authenticated")
353
+ return True
354
+
355
+ warning("gh is not authenticated")
356
+ info("Run [accent]gh auth login[/accent] in your terminal; it opens a browser to sign in.")
357
+ return False
358
+
359
+
360
+ def print_summary(installed, authenticated, auth_checked):
361
+ console.print()
362
+ if not installed:
363
+ console.print(
364
+ Panel(
365
+ f"gh was not installed. Install it manually from [accent]{RELEASES_URL}[/accent], then re-run.",
366
+ title="setup gh incomplete",
367
+ border_style="warning",
368
+ expand=False,
369
+ )
370
+ )
371
+ return
372
+
373
+ lines = [f"GitHub CLI [accent]{gh_version()}[/accent] is ready."]
374
+ if not auth_checked:
375
+ lines.append("Auth check skipped; run [accent]gh auth status[/accent] to see if you are signed in.")
376
+ elif authenticated:
377
+ lines.append("You are signed in; `gh pr`, `gh issue` and Claude Code's GitHub workflows will work.")
378
+ else:
379
+ lines.append("Sign in with [accent]gh auth login[/accent] to finish setting it up.")
380
+
381
+ console.print(
382
+ Panel(
383
+ Text.from_markup("\n".join(lines)),
384
+ title="setup gh complete",
385
+ border_style="success",
386
+ expand=False,
387
+ )
388
+ )
389
+
390
+
391
+ @click.command("gh", epilog=EPILOG)
392
+ @click.option("--reinstall", is_flag=True, help="Install even if gh is already present.")
393
+ @click.option(
394
+ "--user",
395
+ is_flag=True,
396
+ help="Install into ~/.local/bin from the official release; never touch system packages.",
397
+ )
398
+ @click.option("--skip-auth-check", is_flag=True, help="Skip the `gh auth status` check afterwards.")
399
+ def setup_gh(reinstall, user, skip_auth_check):
400
+ """Install the GitHub CLI (gh) and check whether it is signed in.
401
+
402
+ Uses the package manager for your platform - Homebrew on macOS, apt/dnf/
403
+ pacman/apk/zypper on Linux, winget/scoop/choco on Windows - following
404
+ GitHub's official instructions (on Debian/Ubuntu that means adding GitHub's
405
+ keyring and apt source, since gh is not in the default repos).
406
+
407
+ Where a system-wide install is impossible - a container with no root and no
408
+ sudo - it falls back to downloading the official release into ~/.local/bin,
409
+ which needs no privileges. Force that path with --user.
410
+
411
+ An existing install is left alone unless --reinstall is passed. Signing in is
412
+ left to you: `gh auth login` needs an interactive terminal and a browser, so
413
+ this command only reports whether credentials are already present.
414
+ """
415
+ step("evo setup gh")
416
+ installed = install_gh(reinstall, user)
417
+ authenticated = False
418
+ if installed and not skip_auth_check:
419
+ authenticated = check_gh_auth()
420
+ print_summary(installed, authenticated, auth_checked=not skip_auth_check)
@@ -0,0 +1,237 @@
1
+ import hashlib
2
+ import json as jsonlib
3
+ import platform
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ import uuid
8
+
9
+ import rich_click as click
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from evo_cli.console import console, error, info, step
15
+
16
+ # Each identifier: (key, human label, contributes to the composite HWID hash).
17
+ # Only stable, hard-to-change identifiers feed the hash; disk serial and MAC
18
+ # are shown for reference but excluded (disks get swapped, MACs are plural).
19
+ ID_SPECS = [
20
+ ("machine_guid", "Machine GUID", True),
21
+ ("system_uuid", "System UUID", True),
22
+ ("board_serial", "Baseboard serial", True),
23
+ ("bios_serial", "BIOS serial", True),
24
+ ("cpu_id", "CPU ID", True),
25
+ ("disk_serial", "Disk serial", False),
26
+ ("mac", "MAC address", False),
27
+ ]
28
+
29
+ # Placeholder strings firmware vendors leave in serial fields - useless as IDs.
30
+ JUNK_VALUES = {
31
+ "",
32
+ "0",
33
+ "none",
34
+ "n/a",
35
+ "na",
36
+ "null",
37
+ "to be filled by o.e.m.",
38
+ "to be filled by o.e.m",
39
+ "default string",
40
+ "system serial number",
41
+ "system product name",
42
+ "system uuid",
43
+ "00000000-0000-0000-0000-000000000000",
44
+ "ffffffff-ffff-ffff-ffff-ffffffffffff",
45
+ "0123456789",
46
+ }
47
+
48
+ EPILOG = Text.from_markup(
49
+ "[bold]Examples[/bold]\n\n"
50
+ " [cyan]evo hwid[/cyan] show hardware identifiers and the composite HWID\n"
51
+ " [cyan]evo hwid --json[/cyan] machine-readable output\n"
52
+ " [cyan]evo hwid --raw[/cyan] also print the pre-hash composite string\n"
53
+ " [cyan]evo hwid --salt myapp[/cyan] namespace the HWID for a specific app/license\n\n"
54
+ "[dim]The HWID is a SHA-256 over the stable identifiers that are present\n"
55
+ "(machine GUID, system UUID, baseboard / BIOS serial, CPU ID). It stays\n"
56
+ "constant across reboots and reinstalls on the same physical machine.[/dim]"
57
+ )
58
+
59
+
60
+ def _run(cmd, timeout=10):
61
+ """Run a command, return stdout on success, else None (never raises)."""
62
+ try:
63
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
64
+ except (OSError, subprocess.SubprocessError):
65
+ return None
66
+ return out.stdout if out.returncode == 0 else None
67
+
68
+
69
+ def _clean(value):
70
+ """Normalise a raw identifier; drop firmware placeholder junk to None."""
71
+ if value is None:
72
+ return None
73
+ value = str(value).strip()
74
+ if value.lower() in JUNK_VALUES:
75
+ return None
76
+ return value or None
77
+
78
+
79
+ def _read_file(path):
80
+ try:
81
+ with open(path) as handle:
82
+ return handle.read().strip()
83
+ except OSError:
84
+ return None
85
+
86
+
87
+ def _mac_address():
88
+ """Primary NIC MAC as AA:BB:CC:DD:EE:FF, or None if unavailable/random."""
89
+ node = uuid.getnode()
90
+ # getnode() sets the multicast bit when it has to invent a random address.
91
+ if (node >> 40) & 0x1:
92
+ return None
93
+ return ":".join(f"{(node >> shift) & 0xFF:02X}" for shift in range(40, -1, -8))
94
+
95
+
96
+ # --- per-platform collection -------------------------------------------------
97
+ _PS_SCRIPT = (
98
+ "$ErrorActionPreference='SilentlyContinue';"
99
+ "[pscustomobject]@{"
100
+ "machine_guid=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography' -Name MachineGuid).MachineGuid;"
101
+ "system_uuid=(Get-CimInstance Win32_ComputerSystemProduct).UUID;"
102
+ "board_serial=(Get-CimInstance Win32_BaseBoard).SerialNumber;"
103
+ "bios_serial=(Get-CimInstance Win32_BIOS).SerialNumber;"
104
+ "cpu_id=(Get-CimInstance Win32_Processor | Select-Object -First 1).ProcessorId;"
105
+ "disk_serial=(Get-CimInstance Win32_DiskDrive | Select-Object -First 1).SerialNumber"
106
+ "} | ConvertTo-Json -Compress"
107
+ )
108
+
109
+
110
+ def _collect_windows(ids):
111
+ out = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", _PS_SCRIPT])
112
+ if out:
113
+ try:
114
+ parsed = jsonlib.loads(out)
115
+ except (ValueError, TypeError):
116
+ parsed = {}
117
+ for key in ("machine_guid", "system_uuid", "board_serial", "bios_serial", "cpu_id", "disk_serial"):
118
+ ids[key] = _clean(parsed.get(key))
119
+
120
+
121
+ def _collect_darwin(ids):
122
+ out = _run(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"])
123
+ if out:
124
+ uuid_match = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', out)
125
+ serial_match = re.search(r'"IOPlatformSerialNumber"\s*=\s*"([^"]+)"', out)
126
+ ids["system_uuid"] = _clean(uuid_match.group(1) if uuid_match else None)
127
+ ids["board_serial"] = _clean(serial_match.group(1) if serial_match else None)
128
+
129
+
130
+ def _collect_linux(ids):
131
+ ids["machine_guid"] = _clean(_read_file("/etc/machine-id") or _read_file("/var/lib/dbus/machine-id"))
132
+ # DMI fields under /sys; product_uuid and board_serial usually need root.
133
+ ids["system_uuid"] = _clean(_read_file("/sys/class/dmi/id/product_uuid"))
134
+ ids["board_serial"] = _clean(_read_file("/sys/class/dmi/id/board_serial"))
135
+ ids["bios_serial"] = _clean(_read_file("/sys/class/dmi/id/product_serial"))
136
+
137
+
138
+ def collect():
139
+ system = platform.system()
140
+ ids = {key: None for key, _, _ in ID_SPECS}
141
+ if system == "Windows":
142
+ _collect_windows(ids)
143
+ elif system == "Darwin":
144
+ _collect_darwin(ids)
145
+ elif system == "Linux":
146
+ _collect_linux(ids)
147
+ ids["mac"] = _mac_address()
148
+ return system, ids
149
+
150
+
151
+ # --- composite HWID ----------------------------------------------------------
152
+ def compute_hwid(ids, salt=None):
153
+ used = [key for key, _, in_hash in ID_SPECS if in_hash and ids.get(key)]
154
+ if not used:
155
+ return None, None, None, used
156
+ composite = "|".join(ids[key] for key in used)
157
+ raw = f"{salt}|{composite}" if salt else composite
158
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest().upper()
159
+ return digest, digest[:16], composite, used
160
+
161
+
162
+ # --- rendering ---------------------------------------------------------------
163
+ def render(system, ids, hwid, short, composite, used, show_raw):
164
+ table = Table(title="Hardware identifiers", title_style="accent", show_header=True, header_style="accent")
165
+ table.add_column("Identifier", style="info", no_wrap=True)
166
+ table.add_column("Value", style="default", overflow="fold")
167
+ table.add_column("In HWID", justify="center", no_wrap=True)
168
+
169
+ for key, label, in_hash in ID_SPECS:
170
+ value = ids.get(key)
171
+ value_cell = value if value else "[dim]unavailable[/dim]"
172
+ if in_hash:
173
+ mark = "[success]yes[/success]" if value else "[dim]-[/dim]"
174
+ else:
175
+ mark = "[dim]no[/dim]"
176
+ table.add_row(label, value_cell, mark)
177
+ console.print(table)
178
+ console.print(f"[dim]platform: {system or 'unknown'}[/dim]")
179
+
180
+ step("HWID")
181
+ if not hwid:
182
+ error("No stable hardware identifier could be read on this machine.")
183
+ info("On Linux, system UUID and board serial usually need root (try with sudo).")
184
+ return
185
+
186
+ if show_raw:
187
+ console.print(f"[dim]composite:[/dim] {composite}")
188
+ console.print(
189
+ Panel(
190
+ f"[bold accent]{hwid}[/bold accent]\n[dim]short:[/dim] [accent]{short}[/accent]",
191
+ title="HWID (SHA-256)",
192
+ border_style="accent",
193
+ expand=False,
194
+ )
195
+ )
196
+ console.print(f"[dim]derived from: {', '.join(used)}[/dim]")
197
+
198
+
199
+ @click.command("hwid", epilog=EPILOG)
200
+ @click.option("--salt", "salt", default=None, help="Namespace the HWID with an app/license string before hashing.")
201
+ @click.option("--raw", "show_raw", is_flag=True, help="Also print the pre-hash composite string.")
202
+ @click.option("--json", "as_json", is_flag=True, help="Print result as JSON.")
203
+ def hwid(salt, show_raw, as_json):
204
+ """Show this machine's hardware ID (HWID).
205
+
206
+ Reads the stable hardware identifiers available on the current platform -
207
+ **machine GUID**, **system UUID**, **baseboard / BIOS serial** and **CPU
208
+ ID** - then folds them into a single **SHA-256 composite HWID** that stays
209
+ constant across reboots and OS reinstalls on the same physical machine.
210
+
211
+ Disk serial and MAC address are shown for reference but kept out of the hash
212
+ (disks get replaced, machines have several MACs). Use `--salt` to derive a
213
+ per-application HWID, and `--json` for machine-readable output.
214
+ """
215
+ try:
216
+ system, ids = collect()
217
+ hwid_value, short, composite, used = compute_hwid(ids, salt)
218
+
219
+ if as_json:
220
+ payload = {
221
+ "platform": system,
222
+ "identifiers": ids,
223
+ "salt": salt,
224
+ "components": used,
225
+ "hwid": hwid_value,
226
+ "hwid_short": short,
227
+ }
228
+ console.print_json(jsonlib.dumps(payload, ensure_ascii=False))
229
+ return
230
+
231
+ step("evo hwid")
232
+ render(system, ids, hwid_value, short, composite, used, show_raw)
233
+ except click.ClickException:
234
+ raise
235
+ except Exception as exc:
236
+ error(str(exc))
237
+ sys.exit(1)