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.
- {evo_cli-0.11.0 → evo_cli-0.11.2}/PKG-INFO +1 -1
- evo_cli-0.11.2/evo_cli/VERSION +1 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/cli.py +4 -0
- evo_cli-0.11.2/evo_cli/commands/gh.py +420 -0
- evo_cli-0.11.2/evo_cli/commands/hwid.py +237 -0
- evo_cli-0.11.2/evo_cli/commands/hwid_reset.py +197 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/console.py +5 -1
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/SOURCES.txt +2 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_console.py +13 -1
- evo_cli-0.11.2/tests/test_gh.py +285 -0
- evo_cli-0.11.0/evo_cli/VERSION +0 -1
- evo_cli-0.11.0/evo_cli/commands/gh.py +0 -246
- evo_cli-0.11.0/tests/test_gh.py +0 -146
- {evo_cli-0.11.0 → evo_cli-0.11.2}/Containerfile +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/HISTORY.md +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/LICENSE +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/MANIFEST.in +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/README.md +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/__init__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/__main__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/base.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/pyproject.toml +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/setup.cfg +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/__init__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_claude_code.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_cli.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_mcp.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_opencode.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_plantuml.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_sysmon.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.2}/tests/test_wifi.py +0 -0
|
@@ -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)
|