evo-cli 0.11.0__tar.gz → 0.11.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {evo_cli-0.11.0 → evo_cli-0.11.1}/PKG-INFO +1 -1
- evo_cli-0.11.1/evo_cli/VERSION +1 -0
- evo_cli-0.11.1/evo_cli/commands/gh.py +420 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/console.py +5 -1
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_console.py +13 -1
- evo_cli-0.11.1/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.1}/Containerfile +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/HISTORY.md +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/LICENSE +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/MANIFEST.in +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/README.md +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/base.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/cli.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/SOURCES.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/pyproject.toml +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/setup.cfg +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/__init__.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_claude_code.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_cli.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_mcp.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_opencode.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_sysmon.py +0 -0
- {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_wifi.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.11.1
|
|
@@ -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)
|
|
@@ -4,6 +4,7 @@ import sys
|
|
|
4
4
|
import urllib.request
|
|
5
5
|
|
|
6
6
|
from rich.console import Console
|
|
7
|
+
from rich.markup import escape
|
|
7
8
|
from rich.progress import (
|
|
8
9
|
BarColumn,
|
|
9
10
|
DownloadColumn,
|
|
@@ -73,7 +74,10 @@ def resolve_executable(cmd):
|
|
|
73
74
|
|
|
74
75
|
def run_command(cmd, capture=False, check=True, input_text=None, status=None, timeout=None, stdin=None):
|
|
75
76
|
cmd = [str(part) for part in cmd]
|
|
76
|
-
|
|
77
|
+
# Escape the echo: a command containing brackets (an apt sources line, a
|
|
78
|
+
# regex) would otherwise be parsed as rich markup and printed with those
|
|
79
|
+
# parts silently missing, i.e. showing a command we did not run.
|
|
80
|
+
console.print(f"[cmd]$ {escape(' '.join(cmd))}[/cmd]")
|
|
77
81
|
exec_cmd = resolve_executable(cmd)
|
|
78
82
|
|
|
79
83
|
run_kwargs = {
|
|
@@ -3,7 +3,7 @@ import sys
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
|
|
6
|
-
from evo_cli.console import CommandError, run_command
|
|
6
|
+
from evo_cli.console import CommandError, console, run_command
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def test_run_command_raises_on_timeout():
|
|
@@ -21,6 +21,18 @@ def test_run_command_timeout_without_check_returns_exception():
|
|
|
21
21
|
assert isinstance(result, subprocess.TimeoutExpired)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def test_run_command_echo_keeps_brackets(capsys):
|
|
25
|
+
# Rich reads [..] as markup, so an unescaped echo would silently drop the
|
|
26
|
+
# bracketed part and show a command that is not the one we ran.
|
|
27
|
+
with console.capture() as capture:
|
|
28
|
+
run_command([sys.executable, "-c", "pass"], timeout=10)
|
|
29
|
+
assert "[arch=amd64]" not in capture.get() # sanity: no brackets in this command
|
|
30
|
+
|
|
31
|
+
with console.capture() as capture:
|
|
32
|
+
run_command(["echo", "deb [arch=amd64 signed-by=/etc/apt/keyrings/gh.gpg] https://x"], timeout=10)
|
|
33
|
+
assert "deb [arch=amd64 signed-by=/etc/apt/keyrings/gh.gpg] https://x" in capture.get()
|
|
34
|
+
|
|
35
|
+
|
|
24
36
|
def test_run_command_detaches_stdin_when_requested():
|
|
25
37
|
# With stdin=DEVNULL a child that reads stdin sees EOF immediately instead
|
|
26
38
|
# of blocking on an inherited terminal.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import tarfile
|
|
2
|
+
import zipfile
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from evo_cli.cli import cli
|
|
8
|
+
from evo_cli.commands import gh as gh_mod
|
|
9
|
+
from evo_cli.commands.gh import (
|
|
10
|
+
asset_url,
|
|
11
|
+
check_gh_auth,
|
|
12
|
+
extract_gh_binary,
|
|
13
|
+
gh_version,
|
|
14
|
+
install_command,
|
|
15
|
+
install_gh,
|
|
16
|
+
latest_gh_version,
|
|
17
|
+
release_arch,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def runner():
|
|
23
|
+
return CliRunner()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def only_which(*available):
|
|
27
|
+
"""A shutil.which stub that finds only the named binaries."""
|
|
28
|
+
return lambda name: f"/usr/bin/{name}" if name in available else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def unprivileged(monkeypatch):
|
|
33
|
+
"""A plain user: not root, no sudo."""
|
|
34
|
+
monkeypatch.setattr(gh_mod, "is_root", lambda: False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def as_root(monkeypatch):
|
|
39
|
+
monkeypatch.setattr(gh_mod, "is_root", lambda: True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_gh_command_is_registered():
|
|
43
|
+
assert "gh" in cli.commands["setup"].commands
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_gh_help_runs(runner):
|
|
47
|
+
result = runner.invoke(cli, ["setup", "gh", "--help"])
|
|
48
|
+
assert result.exit_code == 0
|
|
49
|
+
assert "GitHub CLI" in result.output
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_install_command_macos_uses_brew(monkeypatch):
|
|
53
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Darwin")
|
|
54
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("brew"))
|
|
55
|
+
assert install_command() == ["brew", "install", "gh"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_install_command_macos_without_brew(monkeypatch):
|
|
59
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Darwin")
|
|
60
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which())
|
|
61
|
+
assert install_command() is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_apt_script_keeps_the_signed_by_source_line(monkeypatch, unprivileged):
|
|
65
|
+
"""The apt source needs the arch/signed-by block; it must survive intact."""
|
|
66
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
67
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("apt-get", "sudo"))
|
|
68
|
+
script = install_command()[2]
|
|
69
|
+
assert "deb [arch=$(dpkg --print-architecture) signed-by=" in script
|
|
70
|
+
assert script.startswith("SUDO=sudo\n")
|
|
71
|
+
assert "$SUDO apt install gh -y" in script
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_apt_script_drops_sudo_when_running_as_root(monkeypatch, as_root):
|
|
75
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
76
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("apt-get"))
|
|
77
|
+
script = install_command()[2]
|
|
78
|
+
assert script.startswith("SUDO=\n")
|
|
79
|
+
assert "sudo" not in script.replace("$SUDO", "")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_pacman_is_elevated_only_when_not_root(monkeypatch, unprivileged):
|
|
83
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
84
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("pacman", "sudo"))
|
|
85
|
+
assert install_command() == ["sudo", "pacman", "-S", "--noconfirm", "github-cli"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_pacman_as_root_needs_no_sudo(monkeypatch, as_root):
|
|
89
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
90
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("pacman"))
|
|
91
|
+
assert install_command() == ["pacman", "-S", "--noconfirm", "github-cli"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_no_system_install_without_root_or_sudo(monkeypatch, unprivileged):
|
|
95
|
+
"""The container case: apt exists, but nothing can elevate to use it."""
|
|
96
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
97
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("apt-get"))
|
|
98
|
+
assert install_command() is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_install_command_windows_winget_is_non_interactive(monkeypatch):
|
|
102
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Windows")
|
|
103
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("winget"))
|
|
104
|
+
command = install_command()
|
|
105
|
+
assert command[:4] == ["winget", "install", "--id", "GitHub.cli"]
|
|
106
|
+
assert "--accept-package-agreements" in command
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_install_command_windows_falls_back_to_scoop(monkeypatch):
|
|
110
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Windows")
|
|
111
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which("scoop"))
|
|
112
|
+
assert install_command() == ["scoop", "install", "gh"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_gh_version_none_when_missing(monkeypatch):
|
|
116
|
+
monkeypatch.setattr(gh_mod.shutil, "which", only_which())
|
|
117
|
+
assert gh_version() is None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_release_arch_maps_uname_machines(monkeypatch):
|
|
121
|
+
monkeypatch.setattr(gh_mod.platform, "machine", lambda: "x86_64")
|
|
122
|
+
assert release_arch() == "amd64"
|
|
123
|
+
monkeypatch.setattr(gh_mod.platform, "machine", lambda: "aarch64")
|
|
124
|
+
assert release_arch() == "arm64"
|
|
125
|
+
monkeypatch.setattr(gh_mod.platform, "machine", lambda: "sparc")
|
|
126
|
+
assert release_arch() is None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_asset_url_per_platform():
|
|
130
|
+
assert asset_url("2.62.0", "amd64", "Linux").endswith("/v2.62.0/gh_2.62.0_linux_amd64.tar.gz")
|
|
131
|
+
assert asset_url("2.62.0", "arm64", "Darwin").endswith("/v2.62.0/gh_2.62.0_macOS_arm64.zip")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_latest_gh_version_reads_the_redirect(monkeypatch):
|
|
135
|
+
class FakeResponse:
|
|
136
|
+
def __enter__(self):
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *exc):
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
def geturl(self):
|
|
143
|
+
return "https://github.com/cli/cli/releases/tag/v2.63.1"
|
|
144
|
+
|
|
145
|
+
monkeypatch.setattr(gh_mod.urllib.request, "urlopen", lambda *a, **kw: FakeResponse())
|
|
146
|
+
assert latest_gh_version() == "2.63.1"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_latest_gh_version_survives_a_network_error(monkeypatch):
|
|
150
|
+
def boom(*args, **kwargs):
|
|
151
|
+
raise OSError("no network")
|
|
152
|
+
|
|
153
|
+
monkeypatch.setattr(gh_mod.urllib.request, "urlopen", boom)
|
|
154
|
+
assert latest_gh_version() is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_extract_gh_binary_from_tarball(tmp_path):
|
|
158
|
+
payload = tmp_path / "gh"
|
|
159
|
+
payload.write_bytes(b"#!/bin/sh\necho gh\n")
|
|
160
|
+
archive = tmp_path / "gh_2.62.0_linux_amd64.tar.gz"
|
|
161
|
+
with tarfile.open(archive, "w:gz") as bundle:
|
|
162
|
+
bundle.add(payload, arcname="gh_2.62.0_linux_amd64/bin/gh")
|
|
163
|
+
|
|
164
|
+
destination = tmp_path / "out" / "gh"
|
|
165
|
+
destination.parent.mkdir()
|
|
166
|
+
assert extract_gh_binary(archive, destination) is True
|
|
167
|
+
assert destination.read_bytes() == b"#!/bin/sh\necho gh\n"
|
|
168
|
+
assert destination.stat().st_mode & 0o111 # executable
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_extract_gh_binary_from_zip(tmp_path):
|
|
172
|
+
archive = tmp_path / "gh_2.62.0_macOS_arm64.zip"
|
|
173
|
+
with zipfile.ZipFile(archive, "w") as bundle:
|
|
174
|
+
bundle.writestr("gh_2.62.0_macOS_arm64/bin/gh", "binary")
|
|
175
|
+
|
|
176
|
+
destination = tmp_path / "gh"
|
|
177
|
+
assert extract_gh_binary(archive, destination) is True
|
|
178
|
+
assert destination.read_text() == "binary"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_extract_gh_binary_without_the_binary(tmp_path):
|
|
182
|
+
archive = tmp_path / "empty.tar.gz"
|
|
183
|
+
with tarfile.open(archive, "w:gz"):
|
|
184
|
+
pass
|
|
185
|
+
assert extract_gh_binary(archive, tmp_path / "gh") is False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_install_gh_skips_when_present(monkeypatch):
|
|
189
|
+
monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
|
|
190
|
+
monkeypatch.setattr(gh_mod, "run_command", lambda *a, **kw: pytest.fail("must not install"))
|
|
191
|
+
assert install_gh() is True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_install_gh_reinstall_runs_the_installer(monkeypatch):
|
|
195
|
+
calls = []
|
|
196
|
+
monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
|
|
197
|
+
monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
|
|
198
|
+
monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: calls.append(cmd))
|
|
199
|
+
assert install_gh(reinstall=True) is True
|
|
200
|
+
assert calls == [["brew", "install", "gh"]]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_install_gh_falls_back_to_release_without_a_package_manager(monkeypatch):
|
|
204
|
+
"""No root, no sudo: the userspace install is the only way through."""
|
|
205
|
+
monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
|
|
206
|
+
monkeypatch.setattr(gh_mod, "install_command", lambda: None)
|
|
207
|
+
monkeypatch.setattr(gh_mod, "can_elevate", lambda: False)
|
|
208
|
+
monkeypatch.setattr(gh_mod, "install_from_release", lambda: True)
|
|
209
|
+
monkeypatch.setattr(gh_mod, "run_command", lambda *a, **kw: pytest.fail("must not install"))
|
|
210
|
+
assert install_gh() is True
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_install_gh_falls_back_to_release_after_a_failed_install(monkeypatch):
|
|
214
|
+
monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
|
|
215
|
+
monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
|
|
216
|
+
monkeypatch.setattr(gh_mod, "install_from_release", lambda: True)
|
|
217
|
+
|
|
218
|
+
def boom(cmd, **kwargs):
|
|
219
|
+
raise gh_mod.CommandError("brew exploded")
|
|
220
|
+
|
|
221
|
+
monkeypatch.setattr(gh_mod, "run_command", boom)
|
|
222
|
+
assert install_gh() is True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_user_flag_never_touches_system_packages(monkeypatch):
|
|
226
|
+
monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
|
|
227
|
+
monkeypatch.setattr(gh_mod, "install_command", lambda: pytest.fail("must not use a package manager"))
|
|
228
|
+
monkeypatch.setattr(gh_mod, "install_from_release", lambda: True)
|
|
229
|
+
assert install_gh(user=True) is True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_install_from_release_writes_the_binary(monkeypatch, tmp_path):
|
|
233
|
+
user_bin = tmp_path / ".local" / "bin"
|
|
234
|
+
monkeypatch.setattr(gh_mod, "USER_BIN", user_bin)
|
|
235
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
236
|
+
monkeypatch.setattr(gh_mod, "release_arch", lambda: "amd64")
|
|
237
|
+
monkeypatch.setattr(gh_mod, "latest_gh_version", lambda: "2.62.0")
|
|
238
|
+
|
|
239
|
+
downloaded = []
|
|
240
|
+
|
|
241
|
+
def fake_download(url, destination, description=""):
|
|
242
|
+
downloaded.append(url)
|
|
243
|
+
with tarfile.open(destination, "w:gz") as bundle:
|
|
244
|
+
payload = tmp_path / "gh"
|
|
245
|
+
payload.write_bytes(b"gh-binary")
|
|
246
|
+
bundle.add(payload, arcname="gh_2.62.0_linux_amd64/bin/gh")
|
|
247
|
+
|
|
248
|
+
monkeypatch.setattr(gh_mod, "download_file", fake_download)
|
|
249
|
+
|
|
250
|
+
assert gh_mod.install_from_release() is True
|
|
251
|
+
assert downloaded == ["https://github.com/cli/cli/releases/download/v2.62.0/gh_2.62.0_linux_amd64.tar.gz"]
|
|
252
|
+
assert (user_bin / "gh").read_bytes() == b"gh-binary"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_install_from_release_reports_an_unknown_arch(monkeypatch):
|
|
256
|
+
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
257
|
+
monkeypatch.setattr(gh_mod, "release_arch", lambda: None)
|
|
258
|
+
monkeypatch.setattr(gh_mod, "download_file", lambda *a, **kw: pytest.fail("must not download"))
|
|
259
|
+
assert gh_mod.install_from_release() is False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class FakeResult:
|
|
263
|
+
def __init__(self, returncode):
|
|
264
|
+
self.returncode = returncode
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_check_gh_auth_signed_in(monkeypatch):
|
|
268
|
+
monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: FakeResult(0))
|
|
269
|
+
assert check_gh_auth() is True
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_check_gh_auth_signed_out(monkeypatch):
|
|
273
|
+
"""`gh auth status` exits non-zero when logged out; that must not be fatal."""
|
|
274
|
+
monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: FakeResult(1))
|
|
275
|
+
assert check_gh_auth() is False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_setup_gh_skip_auth_check(runner, monkeypatch):
|
|
279
|
+
monkeypatch.setattr(gh_mod, "install_gh", lambda reinstall, user: True)
|
|
280
|
+
monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
|
|
281
|
+
monkeypatch.setattr(gh_mod, "check_gh_auth", lambda: pytest.fail("must not check auth"))
|
|
282
|
+
|
|
283
|
+
result = runner.invoke(cli, ["setup", "gh", "--skip-auth-check"])
|
|
284
|
+
assert result.exit_code == 0
|
|
285
|
+
assert "Auth check skipped" in result.output
|
evo_cli-0.11.0/evo_cli/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.11.0
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
"""Install the GitHub CLI (gh) and report whether it is authenticated."""
|
|
2
|
-
|
|
3
|
-
import platform
|
|
4
|
-
import re
|
|
5
|
-
import shutil
|
|
6
|
-
import subprocess
|
|
7
|
-
|
|
8
|
-
import rich_click as click
|
|
9
|
-
from rich.panel import Panel
|
|
10
|
-
from rich.text import Text
|
|
11
|
-
|
|
12
|
-
from evo_cli.console import CommandError, console, error, info, resolve_executable, run_command, step, success, warning
|
|
13
|
-
|
|
14
|
-
EPILOG = Text.from_markup(
|
|
15
|
-
"[bold]Examples[/bold]\n\n"
|
|
16
|
-
" [cyan]evo setup gh[/cyan] install gh, then check its auth status\n"
|
|
17
|
-
" [cyan]evo setup gh --reinstall[/cyan] install again even if gh is present\n"
|
|
18
|
-
" [cyan]evo setup gh --skip-auth-check[/cyan] install only; do not run `gh auth status`"
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
RELEASES_URL = "https://github.com/cli/cli/releases/latest"
|
|
22
|
-
|
|
23
|
-
# gh is not in the default Debian/Ubuntu repositories, so the official
|
|
24
|
-
# instructions add GitHub's keyring and apt source first. Kept close to upstream:
|
|
25
|
-
# https://github.com/cli/cli/blob/trunk/docs/install_linux.md
|
|
26
|
-
APT_SCRIPT = (
|
|
27
|
-
"set -e\n"
|
|
28
|
-
"(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y))\n"
|
|
29
|
-
"sudo mkdir -p -m 755 /etc/apt/keyrings\n"
|
|
30
|
-
"out=$(mktemp)\n"
|
|
31
|
-
"wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg\n"
|
|
32
|
-
"cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null\n"
|
|
33
|
-
"sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg\n"
|
|
34
|
-
"sudo mkdir -p -m 755 /etc/apt/sources.list.d\n"
|
|
35
|
-
'echo "deb [arch=$(dpkg --print-architecture) '
|
|
36
|
-
"signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] "
|
|
37
|
-
'https://cli.github.com/packages stable main" '
|
|
38
|
-
"| sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null\n"
|
|
39
|
-
"sudo apt update\n"
|
|
40
|
-
"sudo apt install gh -y\n"
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
# Fedora ships gh in its own repositories; only fall back to GitHub's rpm repo
|
|
44
|
-
# when the distro does not carry the package.
|
|
45
|
-
DNF_SCRIPT = (
|
|
46
|
-
"sudo dnf install -y gh || {\n"
|
|
47
|
-
" sudo dnf install -y dnf5-plugins\n"
|
|
48
|
-
" sudo dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo\n"
|
|
49
|
-
" sudo dnf install -y gh\n"
|
|
50
|
-
"}\n"
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
ZYPPER_SCRIPT = (
|
|
54
|
-
"set -e\n"
|
|
55
|
-
"sudo zypper addrepo https://cli.github.com/packages/rpm/gh-cli.repo\n"
|
|
56
|
-
"sudo zypper ref\n"
|
|
57
|
-
"sudo zypper install -y gh\n"
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def gh_version():
|
|
62
|
-
"""Return the installed gh version string, or None if gh is not on PATH."""
|
|
63
|
-
if not shutil.which("gh"):
|
|
64
|
-
return None
|
|
65
|
-
try:
|
|
66
|
-
result = subprocess.run(
|
|
67
|
-
resolve_executable(["gh", "--version"]),
|
|
68
|
-
capture_output=True,
|
|
69
|
-
text=True,
|
|
70
|
-
encoding="utf-8",
|
|
71
|
-
errors="replace",
|
|
72
|
-
timeout=30,
|
|
73
|
-
)
|
|
74
|
-
except (OSError, subprocess.SubprocessError):
|
|
75
|
-
return "unknown"
|
|
76
|
-
match = re.search(r"\d+\.\d+\.\d+", result.stdout or "")
|
|
77
|
-
return match.group(0) if match else "unknown"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def linux_install_command():
|
|
81
|
-
if shutil.which("apt-get"):
|
|
82
|
-
return ["bash", "-c", APT_SCRIPT]
|
|
83
|
-
if shutil.which("dnf"):
|
|
84
|
-
return ["bash", "-c", DNF_SCRIPT]
|
|
85
|
-
if shutil.which("pacman"):
|
|
86
|
-
return ["sudo", "pacman", "-S", "--noconfirm", "github-cli"]
|
|
87
|
-
if shutil.which("apk"):
|
|
88
|
-
return ["sudo", "apk", "add", "github-cli"]
|
|
89
|
-
if shutil.which("zypper"):
|
|
90
|
-
return ["bash", "-c", ZYPPER_SCRIPT]
|
|
91
|
-
return None
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def windows_install_command():
|
|
95
|
-
if shutil.which("winget"):
|
|
96
|
-
# The accept flags keep winget from stopping on an interactive agreement prompt.
|
|
97
|
-
return [
|
|
98
|
-
"winget",
|
|
99
|
-
"install",
|
|
100
|
-
"--id",
|
|
101
|
-
"GitHub.cli",
|
|
102
|
-
"--source",
|
|
103
|
-
"winget",
|
|
104
|
-
"--accept-package-agreements",
|
|
105
|
-
"--accept-source-agreements",
|
|
106
|
-
]
|
|
107
|
-
if shutil.which("scoop"):
|
|
108
|
-
return ["scoop", "install", "gh"]
|
|
109
|
-
if shutil.which("choco"):
|
|
110
|
-
return ["choco", "install", "gh", "-y"]
|
|
111
|
-
return None
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def install_command():
|
|
115
|
-
"""The gh install command for this platform, or None if none is usable."""
|
|
116
|
-
system = platform.system()
|
|
117
|
-
if system == "Darwin":
|
|
118
|
-
return ["brew", "install", "gh"] if shutil.which("brew") else None
|
|
119
|
-
if system == "Linux":
|
|
120
|
-
return linux_install_command()
|
|
121
|
-
if system == "Windows":
|
|
122
|
-
return windows_install_command()
|
|
123
|
-
return None
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def missing_package_manager_hint():
|
|
127
|
-
system = platform.system()
|
|
128
|
-
if system == "Darwin":
|
|
129
|
-
info("Homebrew not found. Install it from https://brew.sh, then re-run.")
|
|
130
|
-
elif system == "Linux":
|
|
131
|
-
info("No supported package manager found (apt-get, dnf, pacman, apk, zypper).")
|
|
132
|
-
elif system == "Windows":
|
|
133
|
-
info("No supported package manager found (winget, scoop, choco).")
|
|
134
|
-
else:
|
|
135
|
-
info(f"Unsupported platform: {system}")
|
|
136
|
-
info(f"You can also grab a binary from [accent]{RELEASES_URL}[/accent]")
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def install_gh(reinstall=False):
|
|
140
|
-
"""Install the GitHub CLI. Returns True when `gh` ends up runnable."""
|
|
141
|
-
step("Installing GitHub CLI")
|
|
142
|
-
|
|
143
|
-
version = gh_version()
|
|
144
|
-
if version and not reinstall:
|
|
145
|
-
info(f"gh already installed ({version}); skipping install")
|
|
146
|
-
return True
|
|
147
|
-
|
|
148
|
-
command = install_command()
|
|
149
|
-
if command is None:
|
|
150
|
-
error("Could not install gh automatically.")
|
|
151
|
-
missing_package_manager_hint()
|
|
152
|
-
return False
|
|
153
|
-
|
|
154
|
-
try:
|
|
155
|
-
run_command(command, status="Installing gh", stdin=subprocess.DEVNULL, timeout=900)
|
|
156
|
-
except CommandError as exc:
|
|
157
|
-
error(f"Installing gh failed: {exc}")
|
|
158
|
-
info(f"Install it manually from [accent]{RELEASES_URL}[/accent]")
|
|
159
|
-
return False
|
|
160
|
-
|
|
161
|
-
version = gh_version()
|
|
162
|
-
if not version:
|
|
163
|
-
warning("gh installed but it is not on PATH yet")
|
|
164
|
-
info("Open a new terminal, then run `gh --version` to confirm.")
|
|
165
|
-
return False
|
|
166
|
-
|
|
167
|
-
success(f"gh installed ({version})")
|
|
168
|
-
return True
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def check_gh_auth():
|
|
172
|
-
"""Report whether gh holds GitHub credentials.
|
|
173
|
-
|
|
174
|
-
`gh auth status` is read-only and exits non-zero when logged out, so it is
|
|
175
|
-
safe here. `gh auth login` is not: it needs a TTY and a browser, so leave it
|
|
176
|
-
to the user rather than blocking the run on it.
|
|
177
|
-
"""
|
|
178
|
-
step("Checking GitHub authentication")
|
|
179
|
-
result = run_command(
|
|
180
|
-
["gh", "auth", "status"],
|
|
181
|
-
status="Running gh auth status",
|
|
182
|
-
stdin=subprocess.DEVNULL,
|
|
183
|
-
timeout=60,
|
|
184
|
-
check=False,
|
|
185
|
-
)
|
|
186
|
-
if getattr(result, "returncode", None) == 0:
|
|
187
|
-
success("gh is authenticated")
|
|
188
|
-
return True
|
|
189
|
-
|
|
190
|
-
warning("gh is not authenticated")
|
|
191
|
-
info("Run [accent]gh auth login[/accent] in your terminal; it opens a browser to sign in.")
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def print_summary(installed, authenticated, auth_checked):
|
|
196
|
-
console.print()
|
|
197
|
-
if not installed:
|
|
198
|
-
console.print(
|
|
199
|
-
Panel(
|
|
200
|
-
f"gh was not installed. Install it manually from [accent]{RELEASES_URL}[/accent], then re-run.",
|
|
201
|
-
title="setup gh incomplete",
|
|
202
|
-
border_style="warning",
|
|
203
|
-
expand=False,
|
|
204
|
-
)
|
|
205
|
-
)
|
|
206
|
-
return
|
|
207
|
-
|
|
208
|
-
lines = [f"GitHub CLI [accent]{gh_version()}[/accent] is ready."]
|
|
209
|
-
if not auth_checked:
|
|
210
|
-
lines.append("Auth check skipped; run [accent]gh auth status[/accent] to see if you are signed in.")
|
|
211
|
-
elif authenticated:
|
|
212
|
-
lines.append("You are signed in; `gh pr`, `gh issue` and Claude Code's GitHub workflows will work.")
|
|
213
|
-
else:
|
|
214
|
-
lines.append("Sign in with [accent]gh auth login[/accent] to finish setting it up.")
|
|
215
|
-
|
|
216
|
-
console.print(
|
|
217
|
-
Panel(
|
|
218
|
-
"\n".join(lines),
|
|
219
|
-
title="setup gh complete",
|
|
220
|
-
border_style="success",
|
|
221
|
-
expand=False,
|
|
222
|
-
)
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
@click.command("gh", epilog=EPILOG)
|
|
227
|
-
@click.option("--reinstall", is_flag=True, help="Install even if gh is already present.")
|
|
228
|
-
@click.option("--skip-auth-check", is_flag=True, help="Skip the `gh auth status` check afterwards.")
|
|
229
|
-
def setup_gh(reinstall, skip_auth_check):
|
|
230
|
-
"""Install the GitHub CLI (gh) and check whether it is signed in.
|
|
231
|
-
|
|
232
|
-
Uses the package manager for your platform - Homebrew on macOS, apt/dnf/
|
|
233
|
-
pacman/apk/zypper on Linux, winget/scoop/choco on Windows - following
|
|
234
|
-
GitHub's official instructions (on Debian/Ubuntu that means adding GitHub's
|
|
235
|
-
keyring and apt source, since gh is not in the default repos).
|
|
236
|
-
|
|
237
|
-
An existing install is left alone unless --reinstall is passed. Signing in is
|
|
238
|
-
left to you: `gh auth login` needs an interactive terminal and a browser, so
|
|
239
|
-
this command only reports whether credentials are already present.
|
|
240
|
-
"""
|
|
241
|
-
step("evo setup gh")
|
|
242
|
-
installed = install_gh(reinstall)
|
|
243
|
-
authenticated = False
|
|
244
|
-
if installed and not skip_auth_check:
|
|
245
|
-
authenticated = check_gh_auth()
|
|
246
|
-
print_summary(installed, authenticated, auth_checked=not skip_auth_check)
|
evo_cli-0.11.0/tests/test_gh.py
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from click.testing import CliRunner
|
|
3
|
-
|
|
4
|
-
from evo_cli.cli import cli
|
|
5
|
-
from evo_cli.commands import gh as gh_mod
|
|
6
|
-
from evo_cli.commands.gh import check_gh_auth, gh_version, install_command, install_gh
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@pytest.fixture
|
|
10
|
-
def runner():
|
|
11
|
-
return CliRunner()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def only_which(*available):
|
|
15
|
-
"""A shutil.which stub that finds only the named binaries."""
|
|
16
|
-
return lambda name: f"/usr/bin/{name}" if name in available else None
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_gh_command_is_registered():
|
|
20
|
-
assert "gh" in cli.commands["setup"].commands
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_gh_help_runs(runner):
|
|
24
|
-
result = runner.invoke(cli, ["setup", "gh", "--help"])
|
|
25
|
-
assert result.exit_code == 0
|
|
26
|
-
assert "GitHub CLI" in result.output
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_install_command_macos_uses_brew(monkeypatch):
|
|
30
|
-
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Darwin")
|
|
31
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which("brew"))
|
|
32
|
-
assert install_command() == ["brew", "install", "gh"]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_install_command_macos_without_brew(monkeypatch):
|
|
36
|
-
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Darwin")
|
|
37
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which())
|
|
38
|
-
assert install_command() is None
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_install_command_apt_adds_github_repo(monkeypatch):
|
|
42
|
-
"""gh is not in the default Debian/Ubuntu repos, so the keyring must be set up."""
|
|
43
|
-
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
44
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which("apt-get"))
|
|
45
|
-
command = install_command()
|
|
46
|
-
assert command[:2] == ["bash", "-c"]
|
|
47
|
-
assert "githubcli-archive-keyring.gpg" in command[2]
|
|
48
|
-
assert "sudo apt install gh -y" in command[2]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_install_command_pacman(monkeypatch):
|
|
52
|
-
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
53
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which("pacman"))
|
|
54
|
-
assert install_command() == ["sudo", "pacman", "-S", "--noconfirm", "github-cli"]
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_install_command_linux_without_package_manager(monkeypatch):
|
|
58
|
-
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Linux")
|
|
59
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which())
|
|
60
|
-
assert install_command() is None
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def test_install_command_windows_winget_is_non_interactive(monkeypatch):
|
|
64
|
-
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Windows")
|
|
65
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which("winget"))
|
|
66
|
-
command = install_command()
|
|
67
|
-
assert command[:4] == ["winget", "install", "--id", "GitHub.cli"]
|
|
68
|
-
assert "--accept-package-agreements" in command
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_install_command_windows_falls_back_to_scoop(monkeypatch):
|
|
72
|
-
monkeypatch.setattr(gh_mod.platform, "system", lambda: "Windows")
|
|
73
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which("scoop"))
|
|
74
|
-
assert install_command() == ["scoop", "install", "gh"]
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def test_gh_version_none_when_missing(monkeypatch):
|
|
78
|
-
monkeypatch.setattr(gh_mod.shutil, "which", only_which())
|
|
79
|
-
assert gh_version() is None
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_install_gh_skips_when_present(monkeypatch):
|
|
83
|
-
monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
|
|
84
|
-
monkeypatch.setattr(gh_mod, "run_command", lambda *a, **kw: pytest.fail("must not install"))
|
|
85
|
-
assert install_gh() is True
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def test_install_gh_reinstall_runs_the_installer(monkeypatch):
|
|
89
|
-
calls = []
|
|
90
|
-
monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
|
|
91
|
-
monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
|
|
92
|
-
monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: calls.append(cmd))
|
|
93
|
-
assert install_gh(reinstall=True) is True
|
|
94
|
-
assert calls == [["brew", "install", "gh"]]
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def test_install_gh_fails_without_package_manager(monkeypatch):
|
|
98
|
-
monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
|
|
99
|
-
monkeypatch.setattr(gh_mod, "install_command", lambda: None)
|
|
100
|
-
monkeypatch.setattr(gh_mod, "run_command", lambda *a, **kw: pytest.fail("must not install"))
|
|
101
|
-
assert install_gh() is False
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_install_gh_reports_failed_install(monkeypatch):
|
|
105
|
-
monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
|
|
106
|
-
monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
|
|
107
|
-
|
|
108
|
-
def boom(cmd, **kwargs):
|
|
109
|
-
raise gh_mod.CommandError("brew exploded")
|
|
110
|
-
|
|
111
|
-
monkeypatch.setattr(gh_mod, "run_command", boom)
|
|
112
|
-
assert install_gh() is False
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_install_gh_detects_binary_not_on_path(monkeypatch):
|
|
116
|
-
"""A package manager can succeed while the binary is still not reachable."""
|
|
117
|
-
monkeypatch.setattr(gh_mod, "gh_version", lambda: None)
|
|
118
|
-
monkeypatch.setattr(gh_mod, "install_command", lambda: ["brew", "install", "gh"])
|
|
119
|
-
monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: None)
|
|
120
|
-
assert install_gh() is False
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
class FakeResult:
|
|
124
|
-
def __init__(self, returncode):
|
|
125
|
-
self.returncode = returncode
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_check_gh_auth_signed_in(monkeypatch):
|
|
129
|
-
monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: FakeResult(0))
|
|
130
|
-
assert check_gh_auth() is True
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def test_check_gh_auth_signed_out(monkeypatch):
|
|
134
|
-
"""`gh auth status` exits non-zero when logged out; that must not be fatal."""
|
|
135
|
-
monkeypatch.setattr(gh_mod, "run_command", lambda cmd, **kw: FakeResult(1))
|
|
136
|
-
assert check_gh_auth() is False
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def test_setup_gh_skip_auth_check(runner, monkeypatch):
|
|
140
|
-
monkeypatch.setattr(gh_mod, "install_gh", lambda reinstall: True)
|
|
141
|
-
monkeypatch.setattr(gh_mod, "gh_version", lambda: "2.62.0")
|
|
142
|
-
monkeypatch.setattr(gh_mod, "check_gh_auth", lambda: pytest.fail("must not check auth"))
|
|
143
|
-
|
|
144
|
-
result = runner.invoke(cli, ["setup", "gh", "--skip-auth-check"])
|
|
145
|
-
assert result.exit_code == 0
|
|
146
|
-
assert "Auth check skipped" in result.output
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|