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.
Files changed (51) hide show
  1. {evo_cli-0.11.0 → evo_cli-0.11.1}/PKG-INFO +1 -1
  2. evo_cli-0.11.1/evo_cli/VERSION +1 -0
  3. evo_cli-0.11.1/evo_cli/commands/gh.py +420 -0
  4. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/console.py +5 -1
  5. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/PKG-INFO +1 -1
  6. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_console.py +13 -1
  7. evo_cli-0.11.1/tests/test_gh.py +285 -0
  8. evo_cli-0.11.0/evo_cli/VERSION +0 -1
  9. evo_cli-0.11.0/evo_cli/commands/gh.py +0 -246
  10. evo_cli-0.11.0/tests/test_gh.py +0 -146
  11. {evo_cli-0.11.0 → evo_cli-0.11.1}/Containerfile +0 -0
  12. {evo_cli-0.11.0 → evo_cli-0.11.1}/HISTORY.md +0 -0
  13. {evo_cli-0.11.0 → evo_cli-0.11.1}/LICENSE +0 -0
  14. {evo_cli-0.11.0 → evo_cli-0.11.1}/MANIFEST.in +0 -0
  15. {evo_cli-0.11.0 → evo_cli-0.11.1}/README.md +0 -0
  16. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/__init__.py +0 -0
  17. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/__main__.py +0 -0
  18. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/base.py +0 -0
  19. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/cli.py +0 -0
  20. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/__init__.py +0 -0
  21. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/claude_code.py +0 -0
  22. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/cloudflare.py +0 -0
  23. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/localproxy.py +0 -0
  26. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/mcp.py +0 -0
  27. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/miniconda.py +0 -0
  28. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/netcheck.py +0 -0
  29. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/opencode.py +0 -0
  30. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/plantuml.py +0 -0
  31. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/site2s.py +0 -0
  32. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/ssh.py +0 -0
  33. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/sysmon.py +0 -0
  34. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/commands/wifi.py +0 -0
  35. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli/mcp_registry.py +0 -0
  36. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/SOURCES.txt +0 -0
  37. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  38. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/entry_points.txt +0 -0
  39. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/requires.txt +0 -0
  40. {evo_cli-0.11.0 → evo_cli-0.11.1}/evo_cli.egg-info/top_level.txt +0 -0
  41. {evo_cli-0.11.0 → evo_cli-0.11.1}/pyproject.toml +0 -0
  42. {evo_cli-0.11.0 → evo_cli-0.11.1}/setup.cfg +0 -0
  43. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/__init__.py +0 -0
  44. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_claude_code.py +0 -0
  45. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_cli.py +0 -0
  46. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_fix_claude.py +0 -0
  47. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_mcp.py +0 -0
  48. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_opencode.py +0 -0
  49. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_plantuml.py +0 -0
  50. {evo_cli-0.11.0 → evo_cli-0.11.1}/tests/test_sysmon.py +0 -0
  51. {evo_cli-0.11.0 → evo_cli-0.11.1}/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.1
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.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
- console.print(f"[cmd]$ {' '.join(cmd)}[/cmd]")
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 = {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.11.0
3
+ Version: 0.11.1
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
@@ -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
@@ -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)
@@ -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