ixt-cli 0.8.0__py3-none-any.whl
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.
- ixt/__init__.py +8 -0
- ixt/__main__.py +8 -0
- ixt/backends/__init__.py +1 -0
- ixt/backends/binary.py +935 -0
- ixt/backends/binary_resolver.py +307 -0
- ixt/backends/node.py +490 -0
- ixt/backends/python.py +234 -0
- ixt/cli/__init__.py +31 -0
- ixt/cli/argparse_completion.py +557 -0
- ixt/cli/cmd_apply.py +404 -0
- ixt/cli/cmd_cache.py +86 -0
- ixt/cli/cmd_config.py +295 -0
- ixt/cli/cmd_info.py +116 -0
- ixt/cli/cmd_install.py +508 -0
- ixt/cli/cmd_misc.py +261 -0
- ixt/cli/cmd_registry.py +35 -0
- ixt/cli/cmd_upgrade.py +336 -0
- ixt/cli/commands.py +70 -0
- ixt/cli/parser.py +555 -0
- ixt/cli/render.py +85 -0
- ixt/config/__init__.py +5 -0
- ixt/config/asset_index.py +305 -0
- ixt/config/asset_pattern_cache.py +87 -0
- ixt/config/env_policy.py +340 -0
- ixt/config/flags.py +29 -0
- ixt/config/fs_policy.py +17 -0
- ixt/config/heuristics.py +465 -0
- ixt/config/models.py +176 -0
- ixt/config/registry.py +145 -0
- ixt/config/settings.py +173 -0
- ixt/config/setup_toml.py +179 -0
- ixt/config/toml.py +416 -0
- ixt/core/__init__.py +16 -0
- ixt/core/apply.py +564 -0
- ixt/core/apply_actions.py +106 -0
- ixt/core/backend.py +187 -0
- ixt/core/bootstrap.py +410 -0
- ixt/core/cache.py +332 -0
- ixt/core/discover.py +150 -0
- ixt/core/doctor.py +591 -0
- ixt/core/export.py +419 -0
- ixt/core/expose.py +350 -0
- ixt/core/extract.py +261 -0
- ixt/core/hooks.py +182 -0
- ixt/core/identity.py +148 -0
- ixt/core/inject.py +143 -0
- ixt/core/install.py +509 -0
- ixt/core/install_local.py +229 -0
- ixt/core/locks.py +54 -0
- ixt/core/pathlink.py +86 -0
- ixt/core/resolution_stats.py +191 -0
- ixt/core/resolve.py +150 -0
- ixt/core/resolve_cache.py +185 -0
- ixt/core/runtimes.py +192 -0
- ixt/core/save.py +237 -0
- ixt/core/setup_completions.py +11 -0
- ixt/core/setup_path.py +368 -0
- ixt/core/upgrade.py +596 -0
- ixt/data/__init__.py +10 -0
- ixt/data/asset_index.json +574 -0
- ixt/data/heuristics.toml +98 -0
- ixt/data/registry.toml +71 -0
- ixt/libs/__init__.py +3 -0
- ixt/libs/constants.py +4 -0
- ixt/libs/fmt.py +108 -0
- ixt/libs/logger.py +109 -0
- ixt/libs/output.py +25 -0
- ixt/libs/req_spec.py +115 -0
- ixt/libs/semver.py +149 -0
- ixt/libs/shell.py +126 -0
- ixt/libs/style.py +238 -0
- ixt/net/__init__.py +1 -0
- ixt/net/github_api.py +158 -0
- ixt/net/gitlab_api.py +149 -0
- ixt/net/http.py +194 -0
- ixt/net/npm.py +24 -0
- ixt/net/pypi.py +26 -0
- ixt/net/source.py +163 -0
- ixt/platform/__init__.py +131 -0
- ixt/platform/win.py +68 -0
- ixt_cli-0.8.0.dist-info/METADATA +294 -0
- ixt_cli-0.8.0.dist-info/RECORD +84 -0
- ixt_cli-0.8.0.dist-info/WHEEL +4 -0
- ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/core/doctor.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""Diagnostic checks for ixt health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import site
|
|
9
|
+
import subprocess # nosemgrep: gitlab.bandit.B404
|
|
10
|
+
import tempfile
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ixt.config.settings import Settings, get_settings
|
|
18
|
+
from ixt.core.bootstrap import (
|
|
19
|
+
MIN_BUN_VERSION,
|
|
20
|
+
MIN_UV_VERSION,
|
|
21
|
+
RuntimeName,
|
|
22
|
+
_parse_bun_version,
|
|
23
|
+
_parse_uv_version,
|
|
24
|
+
)
|
|
25
|
+
from ixt.core.cache import count_and_size, humanize_size
|
|
26
|
+
from ixt.libs.shell import command_exists, shell_run
|
|
27
|
+
from ixt.net.http import HttpError, get_bytes, get_final_url
|
|
28
|
+
|
|
29
|
+
_MIN_FREE_BYTES = 100 * 1024 * 1024
|
|
30
|
+
_NETWORK_TIMEOUT_SECONDS = 5
|
|
31
|
+
_LOOSE_VERSION_RE = re.compile(r"v?(\d+(?:\.\d+)*)")
|
|
32
|
+
_NETWORK_TARGETS = (
|
|
33
|
+
("github", "GitHub connectivity", "https://api.github.com/"),
|
|
34
|
+
("pypi", "PyPI connectivity", "https://pypi.org/pypi/pip/json"),
|
|
35
|
+
("npm", "npm registry", "https://registry.npmjs.org/-/ping"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class Check:
|
|
41
|
+
"""Result of a single diagnostic check."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
ok: bool
|
|
45
|
+
detail: str
|
|
46
|
+
hint: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class DoctorReport:
|
|
51
|
+
"""Full diagnostic report."""
|
|
52
|
+
|
|
53
|
+
checks: list[Check] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return all(c.ok for c in self.checks)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def warnings(self) -> list[Check]:
|
|
61
|
+
return [c for c in self.checks if not c.ok]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_doctor(*, settings: Settings | None = None, network: bool = True) -> DoctorReport:
|
|
65
|
+
"""Run all diagnostic checks and return a report."""
|
|
66
|
+
settings = settings or get_settings()
|
|
67
|
+
report = DoctorReport()
|
|
68
|
+
|
|
69
|
+
report.checks.append(_check_bin_in_path(settings))
|
|
70
|
+
report.checks.append(_check_shell())
|
|
71
|
+
report.checks.append(_check_shell_init(settings))
|
|
72
|
+
report.checks.append(_check_home_exists(settings))
|
|
73
|
+
report.checks.append(_check_home_writable(settings))
|
|
74
|
+
report.checks.append(_check_disk_space(settings))
|
|
75
|
+
report.checks.append(_check_install_mode())
|
|
76
|
+
report.checks.append(_check_managed_runtime(_UV_DOCTOR_SPEC, settings, network=network))
|
|
77
|
+
report.checks.append(_check_managed_runtime(_BUN_DOCTOR_SPEC, settings, network=network))
|
|
78
|
+
report.checks.append(_check_system_runtime(_UV_DOCTOR_SPEC))
|
|
79
|
+
report.checks.append(_check_system_runtime(_BUN_DOCTOR_SPEC))
|
|
80
|
+
report.checks.append(_check_github_token())
|
|
81
|
+
report.checks.append(_check_installed_tools(settings))
|
|
82
|
+
report.checks.append(_check_pattern_cache())
|
|
83
|
+
report.checks.append(_check_install_cache(settings))
|
|
84
|
+
report.checks.extend(_check_network(network))
|
|
85
|
+
|
|
86
|
+
return report
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_bin_in_path(settings: Settings) -> Check:
|
|
90
|
+
"""Check that the shims dir is in PATH."""
|
|
91
|
+
bin_dir = str(settings.bin_dir)
|
|
92
|
+
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
|
93
|
+
found = any(os.path.normpath(d) == os.path.normpath(bin_dir) for d in path_dirs)
|
|
94
|
+
if found:
|
|
95
|
+
return Check("PATH", True, f"{bin_dir} is in PATH")
|
|
96
|
+
return Check(
|
|
97
|
+
"PATH",
|
|
98
|
+
False,
|
|
99
|
+
f"{bin_dir} not in PATH",
|
|
100
|
+
hint="Run: ixt setup path",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _check_shell() -> Check:
|
|
105
|
+
"""Report the detected shell and the config file ixt expects to manage."""
|
|
106
|
+
from ixt.core.setup_path import _detect_shell, _target
|
|
107
|
+
|
|
108
|
+
shell = _detect_shell()
|
|
109
|
+
if shell == "unknown":
|
|
110
|
+
return Check("Shell", True, "unknown")
|
|
111
|
+
try:
|
|
112
|
+
target = _target(shell)
|
|
113
|
+
except ValueError:
|
|
114
|
+
return Check("Shell", True, shell)
|
|
115
|
+
return Check("Shell", True, f"{shell} ({target})")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _check_shell_init(settings: Settings) -> Check:
|
|
119
|
+
"""Check whether shell startup config contains the ixt PATH block."""
|
|
120
|
+
from ixt.core.setup_path import _detect_shell, setup_path
|
|
121
|
+
from ixt.platform import is_windows
|
|
122
|
+
|
|
123
|
+
if _detect_shell() == "unknown" and not is_windows():
|
|
124
|
+
return Check(
|
|
125
|
+
"Shell init",
|
|
126
|
+
False,
|
|
127
|
+
"unknown shell; cannot verify shell startup file",
|
|
128
|
+
hint="Run: ixt setup path, then apply the printed snippet manually",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
result = setup_path(check_only=True, settings=settings)
|
|
132
|
+
detail = result.message
|
|
133
|
+
if result.config_file is not None:
|
|
134
|
+
detail = f"{detail} ({result.config_file})"
|
|
135
|
+
if result.warning:
|
|
136
|
+
detail = f"{detail}; {result.warning}"
|
|
137
|
+
|
|
138
|
+
if result.status in ("ok", "ok_restart"):
|
|
139
|
+
return Check("Shell init", True, detail)
|
|
140
|
+
|
|
141
|
+
if result.status == "blocked":
|
|
142
|
+
return Check(
|
|
143
|
+
"Shell init",
|
|
144
|
+
False,
|
|
145
|
+
detail,
|
|
146
|
+
hint="Run: ixt setup path, then apply the printed snippet manually",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return Check("Shell init", False, detail, hint="Run: ixt setup path")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_home_exists(settings: Settings) -> Check:
|
|
153
|
+
"""Check that the IXT_HOME directory structure exists."""
|
|
154
|
+
missing = []
|
|
155
|
+
for d in [
|
|
156
|
+
settings.home,
|
|
157
|
+
settings.config_dir,
|
|
158
|
+
settings.installed_dir,
|
|
159
|
+
settings.bin_dir,
|
|
160
|
+
settings.envs_dir,
|
|
161
|
+
settings.runtimes_dir,
|
|
162
|
+
]:
|
|
163
|
+
if not d.exists():
|
|
164
|
+
missing.append(str(d))
|
|
165
|
+
if not missing:
|
|
166
|
+
return Check("Home", True, str(settings.home))
|
|
167
|
+
return Check(
|
|
168
|
+
"Home",
|
|
169
|
+
False,
|
|
170
|
+
f"Missing: {', '.join(missing)}",
|
|
171
|
+
hint="Directories will be created on first install",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _nearest_existing_parent(path: Path) -> Path | None:
|
|
176
|
+
current = path
|
|
177
|
+
while not current.exists():
|
|
178
|
+
parent = current.parent
|
|
179
|
+
if parent == current:
|
|
180
|
+
return None
|
|
181
|
+
current = parent
|
|
182
|
+
return current
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _check_home_writable(settings: Settings) -> Check:
|
|
186
|
+
"""Check that ``$IXT_HOME`` can be written or created."""
|
|
187
|
+
home = settings.home
|
|
188
|
+
if home.exists() and not home.is_dir():
|
|
189
|
+
return Check("Home writable", False, f"{home} exists but is not a directory")
|
|
190
|
+
|
|
191
|
+
if home.exists():
|
|
192
|
+
try:
|
|
193
|
+
with tempfile.NamedTemporaryFile(prefix=".ixt-doctor-", dir=home):
|
|
194
|
+
pass
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
return Check("Home writable", False, f"{home} is not writable: {exc}")
|
|
197
|
+
return Check("Home writable", True, f"{home} is writable")
|
|
198
|
+
|
|
199
|
+
parent = _nearest_existing_parent(home)
|
|
200
|
+
if parent is None:
|
|
201
|
+
return Check("Home writable", False, f"no existing parent for {home}")
|
|
202
|
+
if os.access(parent, os.W_OK | os.X_OK):
|
|
203
|
+
return Check("Home writable", True, f"{home} can be created under {parent}")
|
|
204
|
+
return Check(
|
|
205
|
+
"Home writable",
|
|
206
|
+
False,
|
|
207
|
+
f"{parent} is not writable",
|
|
208
|
+
hint=f"Fix permissions for {parent}",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _check_disk_space(settings: Settings) -> Check:
|
|
213
|
+
"""Check free disk space under the ixt data root."""
|
|
214
|
+
base = settings.home if settings.home.exists() else _nearest_existing_parent(settings.home)
|
|
215
|
+
if base is None:
|
|
216
|
+
return Check("Disk space", False, f"no existing parent for {settings.home}")
|
|
217
|
+
try:
|
|
218
|
+
usage = shutil.disk_usage(base)
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
return Check("Disk space", False, f"cannot inspect {base}: {exc}")
|
|
221
|
+
free = usage.free
|
|
222
|
+
detail = f"{humanize_size(free)} free under {base}"
|
|
223
|
+
if free >= _MIN_FREE_BYTES:
|
|
224
|
+
return Check("Disk space", True, detail)
|
|
225
|
+
return Check(
|
|
226
|
+
"Disk space",
|
|
227
|
+
False,
|
|
228
|
+
detail,
|
|
229
|
+
hint=f"Free at least {humanize_size(_MIN_FREE_BYTES)} under {base}",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _run_quiet(cmd: list[str]) -> tuple[int, str]:
|
|
234
|
+
"""Run *cmd* with a 5s timeout. Return ``(returncode, stdout)``.
|
|
235
|
+
|
|
236
|
+
Returns ``(1, "")`` on any failure (timeout, missing binary, etc.).
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
result = shell_run(cmd, capture_output=True, check=False, timeout=5)
|
|
240
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
241
|
+
return 1, ""
|
|
242
|
+
return result.returncode, result.stdout
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _pkg_listed_by(bin_name: str, args: list[str]) -> bool:
|
|
246
|
+
"""True iff ``<bin_name> <args>`` lists ``ixt`` on a line of its own."""
|
|
247
|
+
if not command_exists(bin_name):
|
|
248
|
+
return False
|
|
249
|
+
rc, out = _run_quiet([bin_name, *args])
|
|
250
|
+
if rc != 0:
|
|
251
|
+
return False
|
|
252
|
+
return bool(re.search(r"^ixt\b", out, re.MULTILINE))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _uv_tool_has_ixt() -> bool:
|
|
256
|
+
return _pkg_listed_by("uv", ["tool", "list"])
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _pipx_has_ixt() -> bool:
|
|
260
|
+
return _pkg_listed_by("pipx", ["list", "--short"])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _pip_user_has_ixt() -> bool:
|
|
264
|
+
"""True iff `pip show ixt` reports a Location under user site-packages."""
|
|
265
|
+
pip = shutil.which("pip") or shutil.which("pip3")
|
|
266
|
+
if not pip:
|
|
267
|
+
return False
|
|
268
|
+
rc, out = _run_quiet([pip, "show", "ixt"])
|
|
269
|
+
if rc != 0:
|
|
270
|
+
return False
|
|
271
|
+
try:
|
|
272
|
+
user_site = str(Path(site.getusersitepackages()).resolve())
|
|
273
|
+
except OSError:
|
|
274
|
+
return False
|
|
275
|
+
for line in out.splitlines():
|
|
276
|
+
if not line.startswith("Location:"):
|
|
277
|
+
continue
|
|
278
|
+
try:
|
|
279
|
+
location = str(Path(line.split(":", 1)[1].strip()).resolve())
|
|
280
|
+
except OSError:
|
|
281
|
+
return False
|
|
282
|
+
return location == user_site or location.startswith(user_site + os.sep)
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _detect_install_modes() -> list[tuple[str, str]]:
|
|
287
|
+
"""Detect every ixt install across uv tool / pipx / pip --user.
|
|
288
|
+
|
|
289
|
+
Each mechanism is queried independently (``uv tool list``,
|
|
290
|
+
``pipx list --short``, ``pip show ixt``) so that an install dormant
|
|
291
|
+
on PATH but visible to its package manager still surfaces. Multiple
|
|
292
|
+
entries mean ixt is installed via several mechanisms — flagged as
|
|
293
|
+
ambiguous so the user can consolidate.
|
|
294
|
+
"""
|
|
295
|
+
modes: list[tuple[str, str]] = []
|
|
296
|
+
if _uv_tool_has_ixt():
|
|
297
|
+
modes.append(("uv tool", "uv tool upgrade ixt"))
|
|
298
|
+
if _pipx_has_ixt():
|
|
299
|
+
modes.append(("pipx", "pipx upgrade ixt"))
|
|
300
|
+
if _pip_user_has_ixt():
|
|
301
|
+
modes.append(("pip --user", "pip install --user --upgrade ixt"))
|
|
302
|
+
return modes
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _check_install_mode() -> Check:
|
|
306
|
+
"""Detect ixt's install mode and surface the matching upgrade command."""
|
|
307
|
+
modes = _detect_install_modes()
|
|
308
|
+
if len(modes) == 1:
|
|
309
|
+
label, cmd = modes[0]
|
|
310
|
+
return Check("install mode", True, f"installed via {label}", hint=f"upgrade: {cmd}")
|
|
311
|
+
if len(modes) > 1:
|
|
312
|
+
labels = " and ".join(label for label, _ in modes)
|
|
313
|
+
return Check(
|
|
314
|
+
"install mode",
|
|
315
|
+
False,
|
|
316
|
+
f"ambiguous: ixt installed via {labels}",
|
|
317
|
+
hint="Re-install via a single mode to enable upgrade auto-detection",
|
|
318
|
+
)
|
|
319
|
+
return Check("install mode", True, "not detected (dev / from source)")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass(frozen=True)
|
|
323
|
+
class _RuntimeDoctorSpec:
|
|
324
|
+
name: RuntimeName
|
|
325
|
+
min_version: tuple[int, ...]
|
|
326
|
+
parse_version: Callable[[str], tuple[int, ...]]
|
|
327
|
+
managed_path: Callable[[Settings], Path]
|
|
328
|
+
latest_owner: str
|
|
329
|
+
latest_repo: str
|
|
330
|
+
self_upgrade_cmd: str
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class _RuntimeCommand:
|
|
335
|
+
path: str
|
|
336
|
+
version: str | None
|
|
337
|
+
parsed: tuple[int, ...] | None
|
|
338
|
+
error: str | None = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(frozen=True)
|
|
342
|
+
class _LatestRuntime:
|
|
343
|
+
version: str | None
|
|
344
|
+
error: str | None = None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
_UV_DOCTOR_SPEC = _RuntimeDoctorSpec(
|
|
348
|
+
name="uv",
|
|
349
|
+
min_version=MIN_UV_VERSION,
|
|
350
|
+
parse_version=_parse_uv_version,
|
|
351
|
+
managed_path=lambda s: s.uv_runtime,
|
|
352
|
+
latest_owner="astral-sh",
|
|
353
|
+
latest_repo="uv",
|
|
354
|
+
self_upgrade_cmd="uv self update",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
_BUN_DOCTOR_SPEC = _RuntimeDoctorSpec(
|
|
358
|
+
name="bun",
|
|
359
|
+
min_version=MIN_BUN_VERSION,
|
|
360
|
+
parse_version=_parse_bun_version,
|
|
361
|
+
managed_path=lambda s: s.bun_runtime,
|
|
362
|
+
latest_owner="oven-sh",
|
|
363
|
+
latest_repo="bun",
|
|
364
|
+
self_upgrade_cmd="bun upgrade",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _inspect_runtime(path: str, parse_version: Callable[[str], tuple[int, ...]]) -> _RuntimeCommand:
|
|
369
|
+
rc, out = _run_quiet([path, "--version"])
|
|
370
|
+
if rc != 0:
|
|
371
|
+
return _RuntimeCommand(path=path, version=None, parsed=None, error="command failed")
|
|
372
|
+
version = out.strip().splitlines()[0] if out.strip() else "unknown"
|
|
373
|
+
try:
|
|
374
|
+
parsed = parse_version(version)
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
return _RuntimeCommand(path=path, version=version, parsed=None, error=str(exc))
|
|
377
|
+
return _RuntimeCommand(path=path, version=version, parsed=parsed)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _version_tuple_str(version: tuple[int, ...]) -> str:
|
|
381
|
+
return ".".join(str(part) for part in version)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _parse_loose_version(text: str) -> tuple[int, ...] | None:
|
|
385
|
+
match = _LOOSE_VERSION_RE.search(text.strip())
|
|
386
|
+
if match is None:
|
|
387
|
+
return None
|
|
388
|
+
return tuple(int(part) for part in match.group(1).split("."))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _compare_version_tuples(a: tuple[int, ...], b: tuple[int, ...]) -> int:
|
|
392
|
+
length = max(len(a), len(b))
|
|
393
|
+
left = a + (0,) * (length - len(a))
|
|
394
|
+
right = b + (0,) * (length - len(b))
|
|
395
|
+
if left < right:
|
|
396
|
+
return -1
|
|
397
|
+
if left > right:
|
|
398
|
+
return 1
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _latest_runtime_version(spec: _RuntimeDoctorSpec) -> _LatestRuntime:
|
|
403
|
+
from ixt.net.github_api import parse_tag_from_release_url
|
|
404
|
+
|
|
405
|
+
url = f"https://github.com/{spec.latest_owner}/{spec.latest_repo}/releases/latest"
|
|
406
|
+
try:
|
|
407
|
+
final = get_final_url(url, timeout=_NETWORK_TIMEOUT_SECONDS)
|
|
408
|
+
except HttpError as exc:
|
|
409
|
+
return _LatestRuntime(None, str(exc))
|
|
410
|
+
tag = parse_tag_from_release_url(final)
|
|
411
|
+
if tag is None:
|
|
412
|
+
return _LatestRuntime(None, f"unexpected redirect: {final}")
|
|
413
|
+
return _LatestRuntime(tag, None)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _latest_detail(latest: _LatestRuntime | None, *, network: bool) -> str:
|
|
417
|
+
if not network:
|
|
418
|
+
return "latest skipped (--no-network)"
|
|
419
|
+
if latest is None or latest.version is None:
|
|
420
|
+
error = latest.error if latest else "unknown"
|
|
421
|
+
return f"latest unavailable ({error})"
|
|
422
|
+
return f"latest {latest.version}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _runtime_upgrade_hint(name: RuntimeName) -> str:
|
|
426
|
+
return f"Run: ixt runtime upgrade {name}"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _system_runtime_hint(spec: _RuntimeDoctorSpec) -> str:
|
|
430
|
+
return f"Run: {spec.self_upgrade_cmd}; or: ixt runtime upgrade {spec.name}"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _check_managed_runtime(
|
|
434
|
+
spec: _RuntimeDoctorSpec,
|
|
435
|
+
settings: Settings,
|
|
436
|
+
*,
|
|
437
|
+
network: bool,
|
|
438
|
+
) -> Check:
|
|
439
|
+
path = spec.managed_path(settings)
|
|
440
|
+
latest = _latest_runtime_version(spec) if network else None
|
|
441
|
+
latest_text = _latest_detail(latest, network=network)
|
|
442
|
+
name = f"{spec.name} runtime"
|
|
443
|
+
|
|
444
|
+
if not path.exists():
|
|
445
|
+
return Check(
|
|
446
|
+
name,
|
|
447
|
+
False,
|
|
448
|
+
f"missing at {path}; {latest_text}",
|
|
449
|
+
hint=_runtime_upgrade_hint(spec.name),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
status = _inspect_runtime(str(path), spec.parse_version)
|
|
453
|
+
if status.error or status.version is None or status.parsed is None:
|
|
454
|
+
return Check(
|
|
455
|
+
name,
|
|
456
|
+
False,
|
|
457
|
+
f"broken at {path}: {status.error or 'unknown error'}; {latest_text}",
|
|
458
|
+
hint=_runtime_upgrade_hint(spec.name),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
detail = f"local {status.version} at {path}; {latest_text}"
|
|
462
|
+
if _compare_version_tuples(status.parsed, spec.min_version) < 0:
|
|
463
|
+
min_v = _version_tuple_str(spec.min_version)
|
|
464
|
+
return Check(
|
|
465
|
+
name,
|
|
466
|
+
False,
|
|
467
|
+
f"{detail}; minimum {min_v}",
|
|
468
|
+
hint=_runtime_upgrade_hint(spec.name),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
latest_parsed = _parse_loose_version(latest.version) if latest and latest.version else None
|
|
472
|
+
if latest_parsed is not None and _compare_version_tuples(status.parsed, latest_parsed) < 0:
|
|
473
|
+
return Check(name, False, detail, hint=_runtime_upgrade_hint(spec.name))
|
|
474
|
+
|
|
475
|
+
return Check(name, True, detail)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _check_system_runtime(spec: _RuntimeDoctorSpec) -> Check:
|
|
479
|
+
path = shutil.which(spec.name)
|
|
480
|
+
name = f"system {spec.name}"
|
|
481
|
+
if path is None:
|
|
482
|
+
return Check(name, True, "not on PATH")
|
|
483
|
+
|
|
484
|
+
status = _inspect_runtime(path, spec.parse_version)
|
|
485
|
+
if status.error or status.version is None or status.parsed is None:
|
|
486
|
+
return Check(
|
|
487
|
+
name,
|
|
488
|
+
False,
|
|
489
|
+
f"broken at {path}: {status.error or 'unknown error'}",
|
|
490
|
+
hint=_system_runtime_hint(spec),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
detail = f"{status.version} at {path}"
|
|
494
|
+
if _compare_version_tuples(status.parsed, spec.min_version) < 0:
|
|
495
|
+
min_v = _version_tuple_str(spec.min_version)
|
|
496
|
+
return Check(name, False, f"{detail}; minimum {min_v}", hint=_system_runtime_hint(spec))
|
|
497
|
+
return Check(name, True, detail)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _check_github_token() -> Check:
|
|
501
|
+
"""Check GITHUB_TOKEN / GH_TOKEN availability."""
|
|
502
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
503
|
+
if token:
|
|
504
|
+
return Check("GitHub token", True, "set (5000 req/h)")
|
|
505
|
+
return Check(
|
|
506
|
+
"GitHub token",
|
|
507
|
+
False,
|
|
508
|
+
"not set (60 req/h)",
|
|
509
|
+
hint="export GITHUB_TOKEN=$(gh auth token)",
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _check_installed_tools(settings: Settings) -> Check:
|
|
514
|
+
"""Count installed tools per backend."""
|
|
515
|
+
counts: dict[str, int] = {"python": 0, "node": 0, "binary": 0}
|
|
516
|
+
total = 0
|
|
517
|
+
for meta_file in settings.iter_installed_metadata():
|
|
518
|
+
total += 1
|
|
519
|
+
try:
|
|
520
|
+
from ixt.config.models import ToolRecord
|
|
521
|
+
|
|
522
|
+
record = ToolRecord.load_json(meta_file)
|
|
523
|
+
counts[record.backend] = counts.get(record.backend, 0) + 1
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
parts = [f"{v} {k}" for k, v in counts.items() if v > 0]
|
|
527
|
+
detail = f"{total} tools" + (f" ({', '.join(parts)})" if parts else "")
|
|
528
|
+
return Check("Tools", True, detail)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _check_pattern_cache() -> Check:
|
|
532
|
+
"""Count learned asset patterns in the metadata cache."""
|
|
533
|
+
from ixt.config import asset_pattern_cache
|
|
534
|
+
|
|
535
|
+
path = asset_pattern_cache._cache_path()
|
|
536
|
+
count = len(asset_pattern_cache._load())
|
|
537
|
+
plural = "pattern" if count == 1 else "patterns"
|
|
538
|
+
return Check("Pattern cache", True, f"{count} {plural} — {path}")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _check_install_cache(settings: Settings) -> Check:
|
|
542
|
+
"""Report size of the downloads cache."""
|
|
543
|
+
path = settings.downloads_dir
|
|
544
|
+
_, size = count_and_size(path)
|
|
545
|
+
return Check("Downloads cache", True, f"{humanize_size(size)} — {path}")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _probe_url(target_id: str) -> None:
|
|
549
|
+
url = ""
|
|
550
|
+
for candidate_id, _name, candidate_url in _NETWORK_TARGETS:
|
|
551
|
+
if candidate_id == target_id:
|
|
552
|
+
url = candidate_url
|
|
553
|
+
break
|
|
554
|
+
if not url:
|
|
555
|
+
raise OSError(f"URL not allowed for doctor probe: {target_id}")
|
|
556
|
+
parsed = urllib.parse.urlparse(url)
|
|
557
|
+
if parsed.scheme != "https" or parsed.username:
|
|
558
|
+
raise OSError(f"URL not allowed for doctor probe: {target_id}")
|
|
559
|
+
get_bytes(url, headers={"User-Agent": "ixt-doctor/1"}, timeout=_NETWORK_TIMEOUT_SECONDS)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _check_network(enabled: bool) -> list[Check]:
|
|
563
|
+
if not enabled:
|
|
564
|
+
return [
|
|
565
|
+
Check(name, True, "skipped (--no-network)") for _target_id, name, _ in _NETWORK_TARGETS
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
checks: list[Check] = []
|
|
569
|
+
for target_id, name, url in _NETWORK_TARGETS:
|
|
570
|
+
try:
|
|
571
|
+
_probe_url(target_id)
|
|
572
|
+
except (HttpError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
|
|
573
|
+
checks.append(
|
|
574
|
+
Check(
|
|
575
|
+
name,
|
|
576
|
+
False,
|
|
577
|
+
f"failed: {exc}",
|
|
578
|
+
hint="Check DNS/proxy/firewall, or rerun: ixt doctor --no-network",
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
else:
|
|
582
|
+
checks.append(Check(name, True, url))
|
|
583
|
+
return checks
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _get_cmd_version(cmd: str) -> str:
|
|
587
|
+
"""Get version string from a command."""
|
|
588
|
+
rc, out = _run_quiet([cmd, "--version"])
|
|
589
|
+
if rc != 0:
|
|
590
|
+
return "unknown"
|
|
591
|
+
return out.strip().split("\n")[0] or "unknown"
|