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.
Files changed (84) hide show
  1. ixt/__init__.py +8 -0
  2. ixt/__main__.py +8 -0
  3. ixt/backends/__init__.py +1 -0
  4. ixt/backends/binary.py +935 -0
  5. ixt/backends/binary_resolver.py +307 -0
  6. ixt/backends/node.py +490 -0
  7. ixt/backends/python.py +234 -0
  8. ixt/cli/__init__.py +31 -0
  9. ixt/cli/argparse_completion.py +557 -0
  10. ixt/cli/cmd_apply.py +404 -0
  11. ixt/cli/cmd_cache.py +86 -0
  12. ixt/cli/cmd_config.py +295 -0
  13. ixt/cli/cmd_info.py +116 -0
  14. ixt/cli/cmd_install.py +508 -0
  15. ixt/cli/cmd_misc.py +261 -0
  16. ixt/cli/cmd_registry.py +35 -0
  17. ixt/cli/cmd_upgrade.py +336 -0
  18. ixt/cli/commands.py +70 -0
  19. ixt/cli/parser.py +555 -0
  20. ixt/cli/render.py +85 -0
  21. ixt/config/__init__.py +5 -0
  22. ixt/config/asset_index.py +305 -0
  23. ixt/config/asset_pattern_cache.py +87 -0
  24. ixt/config/env_policy.py +340 -0
  25. ixt/config/flags.py +29 -0
  26. ixt/config/fs_policy.py +17 -0
  27. ixt/config/heuristics.py +465 -0
  28. ixt/config/models.py +176 -0
  29. ixt/config/registry.py +145 -0
  30. ixt/config/settings.py +173 -0
  31. ixt/config/setup_toml.py +179 -0
  32. ixt/config/toml.py +416 -0
  33. ixt/core/__init__.py +16 -0
  34. ixt/core/apply.py +564 -0
  35. ixt/core/apply_actions.py +106 -0
  36. ixt/core/backend.py +187 -0
  37. ixt/core/bootstrap.py +410 -0
  38. ixt/core/cache.py +332 -0
  39. ixt/core/discover.py +150 -0
  40. ixt/core/doctor.py +591 -0
  41. ixt/core/export.py +419 -0
  42. ixt/core/expose.py +350 -0
  43. ixt/core/extract.py +261 -0
  44. ixt/core/hooks.py +182 -0
  45. ixt/core/identity.py +148 -0
  46. ixt/core/inject.py +143 -0
  47. ixt/core/install.py +509 -0
  48. ixt/core/install_local.py +229 -0
  49. ixt/core/locks.py +54 -0
  50. ixt/core/pathlink.py +86 -0
  51. ixt/core/resolution_stats.py +191 -0
  52. ixt/core/resolve.py +150 -0
  53. ixt/core/resolve_cache.py +185 -0
  54. ixt/core/runtimes.py +192 -0
  55. ixt/core/save.py +237 -0
  56. ixt/core/setup_completions.py +11 -0
  57. ixt/core/setup_path.py +368 -0
  58. ixt/core/upgrade.py +596 -0
  59. ixt/data/__init__.py +10 -0
  60. ixt/data/asset_index.json +574 -0
  61. ixt/data/heuristics.toml +98 -0
  62. ixt/data/registry.toml +71 -0
  63. ixt/libs/__init__.py +3 -0
  64. ixt/libs/constants.py +4 -0
  65. ixt/libs/fmt.py +108 -0
  66. ixt/libs/logger.py +109 -0
  67. ixt/libs/output.py +25 -0
  68. ixt/libs/req_spec.py +115 -0
  69. ixt/libs/semver.py +149 -0
  70. ixt/libs/shell.py +126 -0
  71. ixt/libs/style.py +238 -0
  72. ixt/net/__init__.py +1 -0
  73. ixt/net/github_api.py +158 -0
  74. ixt/net/gitlab_api.py +149 -0
  75. ixt/net/http.py +194 -0
  76. ixt/net/npm.py +24 -0
  77. ixt/net/pypi.py +26 -0
  78. ixt/net/source.py +163 -0
  79. ixt/platform/__init__.py +131 -0
  80. ixt/platform/win.py +68 -0
  81. ixt_cli-0.8.0.dist-info/METADATA +294 -0
  82. ixt_cli-0.8.0.dist-info/RECORD +84 -0
  83. ixt_cli-0.8.0.dist-info/WHEEL +4 -0
  84. 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"