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/backends/binary.py ADDED
@@ -0,0 +1,935 @@
1
+ """Binary backend — GitHub/GitLab Releases via heuristic asset selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, TypedDict
10
+
11
+ from ixt.backends.binary_resolver import _fast_path_release, derive_asset_pattern
12
+ from ixt.config.asset_index import (
13
+ GitHubApiDisabledError,
14
+ disabled_api_message,
15
+ is_github_api_allowed,
16
+ )
17
+ from ixt.config.flags import is_setup_toml_enabled
18
+ from ixt.config.heuristics import (
19
+ get_name_suffix_tokens,
20
+ get_platform_aliases,
21
+ load_heuristics,
22
+ select_asset,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from ixt.config.setup_toml import SetupConfig
27
+ from ixt.config.settings import Settings, get_settings
28
+ from ixt.core.backend import BackendType
29
+ from ixt.core.discover import find_executables
30
+ from ixt.core.extract import extract, is_archive
31
+ from ixt.libs.logger import get_logger, get_verbosity
32
+ from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
33
+
34
+ __all__ = [
35
+ "AssetSelectionError",
36
+ "BinaryBackend",
37
+ "BinarySpecError",
38
+ "HookEntry",
39
+ "SetupMetadata",
40
+ "_fast_path_release",
41
+ "derive_asset_pattern",
42
+ "make_source",
43
+ ]
44
+
45
+
46
+ # 3.10 lacks typing.NotRequired (added in 3.11) and the project bans
47
+ # third-party deps (typing_extensions). The required-base + total=False
48
+ # subclass pattern is the canonical 3.10 stdlib alternative.
49
+
50
+
51
+ class _HookEntryRequired(TypedDict):
52
+ run: list[str] | str
53
+
54
+
55
+ class HookEntry(_HookEntryRequired, total=False):
56
+ """Persisted lifecycle hook entry inside ``binary_metadata.json``."""
57
+
58
+ check: list[str] | str
59
+
60
+
61
+ class _SetupMetadataRequired(TypedDict):
62
+ name: str | None
63
+ expose: list[str]
64
+ description: str | None
65
+ asset_pattern: str | None
66
+
67
+
68
+ class SetupMetadata(_SetupMetadataRequired, total=False):
69
+ """``setup`` sub-document stored in ``binary_metadata.json``.
70
+
71
+ Mirrors the relevant subset of ``SetupConfig`` — the bits the tool
72
+ runtime needs after install (expose filter, hook commands).
73
+ """
74
+
75
+ post_install: HookEntry
76
+ pre_uninstall: HookEntry
77
+
78
+
79
+ class AssetSelectionError(RuntimeError):
80
+ """Raised when no suitable asset could be selected for the current platform."""
81
+
82
+ def __init__(self, spec: str, release_tag: str, asset_names: list[str]):
83
+ self.spec = spec
84
+ self.release_tag = release_tag
85
+ self.asset_names = asset_names
86
+ names = ", ".join(asset_names[:10]) or "<none>"
87
+ super().__init__(
88
+ f"No suitable asset found for '{spec}' (release {release_tag}). Available: {names}"
89
+ )
90
+
91
+
92
+ class BinarySpecError(ValueError):
93
+ """Raised when a spec cannot be parsed as a binary backend spec."""
94
+
95
+ def __init__(self, spec: str):
96
+ self.spec = spec
97
+ super().__init__(f"Invalid binary spec: {spec!r} (expected owner/repo[@version])")
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Metadata stored alongside the extracted environment
102
+ # ---------------------------------------------------------------------------
103
+
104
+ _META_FILE = "binary_metadata.json"
105
+
106
+
107
+ def _encode_path_under_env(path: str, env_dir: Path) -> str:
108
+ candidate = Path(path)
109
+ if not candidate.is_absolute():
110
+ return path
111
+ try:
112
+ return str(candidate.absolute().relative_to(env_dir.absolute()))
113
+ except ValueError:
114
+ return path
115
+
116
+
117
+ def _decode_path_from_env(path: str, env_dir: Path) -> str:
118
+ candidate = Path(path)
119
+ if candidate.is_absolute():
120
+ return path
121
+ return str(env_dir / candidate)
122
+
123
+
124
+ def _existing_metadata_binaries(meta: dict, env_dir: Path) -> dict[str, str]:
125
+ binaries = meta.get("binaries", {})
126
+ if not isinstance(binaries, dict):
127
+ return {}
128
+ existing: dict[str, str] = {}
129
+ for name, bin_path in binaries.items():
130
+ path = Path(bin_path)
131
+ if path.exists():
132
+ existing[str(name)] = str(path)
133
+ return existing
134
+
135
+
136
+ def _write_metadata(
137
+ env_dir: Path,
138
+ *,
139
+ spec: str,
140
+ tag: str,
141
+ asset_name: str,
142
+ binaries: dict[str, str],
143
+ setup: SetupMetadata | None = None,
144
+ resolution_source: str | None = None,
145
+ ) -> Path:
146
+ """Write binary backend metadata to env_dir."""
147
+ path = env_dir / _META_FILE
148
+ data: dict = {
149
+ "spec": spec,
150
+ "tag": tag,
151
+ "asset_name": asset_name,
152
+ "binaries": {
153
+ name: _encode_path_under_env(path, env_dir) for name, path in binaries.items()
154
+ },
155
+ }
156
+ if setup is not None:
157
+ data["setup"] = setup
158
+ if resolution_source:
159
+ data["resolution_source"] = resolution_source
160
+ path.parent.mkdir(parents=True, exist_ok=True)
161
+ with path.open("w", encoding="utf-8") as f:
162
+ json.dump(data, f, indent=2)
163
+ f.write("\n")
164
+ return path
165
+
166
+
167
+ def _read_metadata(env_dir: Path) -> dict | None:
168
+ """Read binary backend metadata, or None if absent."""
169
+ path = env_dir / _META_FILE
170
+ if not path.exists():
171
+ return None
172
+ with path.open(encoding="utf-8") as f:
173
+ data = json.load(f)
174
+ binaries = data.get("binaries")
175
+ if isinstance(binaries, dict):
176
+ data["binaries"] = {
177
+ name: _decode_path_from_env(path, env_dir) for name, path in binaries.items()
178
+ }
179
+ return data
180
+
181
+
182
+ def _build_setup_metadata(setup: SetupConfig | None) -> SetupMetadata | None:
183
+ """Serialize a SetupConfig into the JSON-ready metadata dict."""
184
+ if setup is None:
185
+ return None
186
+ data: SetupMetadata = {
187
+ "name": setup.name,
188
+ "expose": setup.expose,
189
+ "description": setup.description,
190
+ "asset_pattern": setup.asset_pattern,
191
+ }
192
+ if setup.post_install.run:
193
+ post: HookEntry = {"run": setup.post_install.run}
194
+ if setup.post_install.check:
195
+ post["check"] = setup.post_install.check
196
+ data["post_install"] = post
197
+ if setup.pre_uninstall.run:
198
+ pre: HookEntry = {"run": setup.pre_uninstall.run}
199
+ if setup.pre_uninstall.check:
200
+ pre["check"] = setup.pre_uninstall.check
201
+ data["pre_uninstall"] = pre
202
+ return data
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Release source factory
207
+ # ---------------------------------------------------------------------------
208
+
209
+
210
+ def make_source(repo_spec: RepoSpec) -> ReleaseSource:
211
+ """Create the appropriate ReleaseSource for a parsed spec.
212
+
213
+ For GitLab, ``base_url`` is derived from ``repo_spec.host`` so
214
+ self-hosted instances (e.g. ``gitlab.company.com``) are reached
215
+ instead of ``gitlab.com``.
216
+ """
217
+ if repo_spec.platform == "github":
218
+ from ixt.net.github_api import GitHubSource
219
+
220
+ return GitHubSource()
221
+
222
+ from ixt.net.gitlab_api import GitLabSource
223
+
224
+ base_url = f"https://{repo_spec.host}"
225
+ return GitLabSource(base_url=base_url)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Cache helpers
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def _cache_dir_for(settings: Settings, repo_spec: RepoSpec) -> Path:
234
+ """Return the cache directory for a given repo spec.
235
+
236
+ Layout: ``$IXT_CACHE_HOME/downloads/{host}/{owner}-{repo}/``
237
+ """
238
+ host = repo_spec.host.replace(":", "_")
239
+ return settings.cache_dir / host / f"{repo_spec.owner}-{repo_spec.repo}"
240
+
241
+
242
+ def _cached_asset(cache_dir: Path, asset_name: str) -> Path | None:
243
+ """Return the cached asset path if it exists."""
244
+ path = cache_dir / asset_name
245
+ if path.exists() and path.stat().st_size > 0:
246
+ return path
247
+ return None
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Version extraction
252
+ # ---------------------------------------------------------------------------
253
+
254
+ _BARE_NAME_SEPARATORS = "._-"
255
+ _VERSION_SUFFIX_RE = re.compile(
256
+ rf"(^|[{re.escape(_BARE_NAME_SEPARATORS)}])"
257
+ rf"v?\d+(?:\.\d+){{1,}}(?:[-+][0-9A-Za-z.]+)?$",
258
+ re.IGNORECASE,
259
+ )
260
+
261
+
262
+ def _strip_v_prefix(tag: str) -> str:
263
+ """Strip leading 'v' from a version tag: 'v14.1.0' -> '14.1.0'."""
264
+ if tag.startswith("v") and len(tag) > 1 and tag[1:2].isdigit():
265
+ return tag[1:]
266
+ return tag
267
+
268
+
269
+ def _tag_candidates(tag: str) -> list[str]:
270
+ """Return direct-download tag candidates, preserving exact-tag priority."""
271
+ if tag.startswith("v"):
272
+ return [tag]
273
+ return [tag, f"v{tag}"]
274
+
275
+
276
+ def _selection_pattern(pattern: str | None, tag: str) -> str | None:
277
+ """Return a pattern suitable for the existing selector.
278
+
279
+ The selection layer knows ``{version}``, ``{os}``, ``{arch}``, and
280
+ ``{name}``. Direct-download patterns may also use ``{tag}``, so replace
281
+ that placeholder before matching the synthesized one-asset release.
282
+ """
283
+ if pattern is None:
284
+ return None
285
+ return pattern.replace("{tag}", tag)
286
+
287
+
288
+ def _strip_exe_suffix(name: str) -> str:
289
+ """Return *name* without a Windows executable suffix, if present."""
290
+ return name[:-4] if name.lower().endswith(".exe") else name
291
+
292
+
293
+ def _strip_trailing_token(name: str, token: str) -> str:
294
+ """Strip *token* when it is the final separator-delimited name token."""
295
+ cleaned = token.strip(_BARE_NAME_SEPARATORS)
296
+ if not cleaned:
297
+ return name
298
+
299
+ lower = name.lower()
300
+ token_lower = cleaned.lower()
301
+ if lower == token_lower:
302
+ return ""
303
+
304
+ for sep in _BARE_NAME_SEPARATORS:
305
+ suffix = f"{sep}{token_lower}"
306
+ if lower.endswith(suffix):
307
+ return name[: -len(suffix)].rstrip(_BARE_NAME_SEPARATORS)
308
+ return name
309
+
310
+
311
+ def _strip_trailing_tokens(name: str, tokens: list[str]) -> str:
312
+ """Repeatedly strip known final tokens, longest aliases first."""
313
+ current = name
314
+ aliases = sorted({token for token in tokens if token}, key=len, reverse=True)
315
+ changed = True
316
+ while current and changed:
317
+ changed = False
318
+ for token in aliases:
319
+ stripped = _strip_trailing_token(current, token)
320
+ if stripped != current:
321
+ current = stripped
322
+ changed = True
323
+ break
324
+ return current
325
+
326
+
327
+ def _strip_trailing_version(name: str, tag: str) -> str:
328
+ """Strip a final release-version token from *name*."""
329
+ version = _strip_v_prefix(tag)
330
+ candidates = [tag, version]
331
+ if version:
332
+ candidates.append(f"v{version}")
333
+
334
+ stripped = _strip_trailing_tokens(name, candidates)
335
+ if stripped != name:
336
+ return stripped
337
+
338
+ return _VERSION_SUFFIX_RE.sub("", name).strip(_BARE_NAME_SEPARATORS)
339
+
340
+
341
+ def _derive_bare_binary_name(asset_name: str, fallback_name: str, tag: str) -> str:
342
+ """Infer the executable name for a bare binary asset.
343
+
344
+ Bare release assets are the executable itself. Keep that source of truth,
345
+ but remove common version/platform suffixes such as
346
+ ``shfmt_v3.13.1_linux_amd64`` -> ``shfmt``.
347
+ """
348
+ current = _strip_exe_suffix(asset_name).strip(_BARE_NAME_SEPARATORS)
349
+ os_aliases, arch_aliases = get_platform_aliases()
350
+ suffix_tokens = [*os_aliases, *arch_aliases, *get_name_suffix_tokens()]
351
+
352
+ changed = True
353
+ while current and changed:
354
+ changed = False
355
+ stripped = _strip_trailing_tokens(current, suffix_tokens)
356
+ stripped = _strip_trailing_version(stripped, tag)
357
+ if stripped != current:
358
+ current = stripped
359
+ changed = True
360
+
361
+ return current or _strip_exe_suffix(fallback_name)
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # BinaryBackend
366
+ # ---------------------------------------------------------------------------
367
+
368
+
369
+ @dataclass
370
+ class BinaryBackend:
371
+ """Install binary tools from GitHub/GitLab Releases."""
372
+
373
+ settings: Settings = field(default_factory=get_settings)
374
+ backend_type: BackendType = field(default=BackendType.BINARY, init=False)
375
+
376
+ # -- Backend protocol ---------------------------------------------------
377
+
378
+ def env_exists(self, env_dir: Path) -> bool:
379
+ """Check whether extracted binary content exists."""
380
+ meta = env_dir / _META_FILE
381
+ return meta.exists()
382
+
383
+ def create_env(self, env_dir: Path) -> bool:
384
+ """Create the environment directory. Return True if newly created."""
385
+ if env_dir.exists() and self.env_exists(env_dir):
386
+ return False
387
+ env_dir.mkdir(parents=True, exist_ok=True)
388
+ return True
389
+
390
+ def install_packages(
391
+ self,
392
+ env_dir: Path,
393
+ specs: list[str],
394
+ *,
395
+ upgrade: bool = False,
396
+ setup_path: Path | None = None,
397
+ asset_pattern: str | None = None,
398
+ known_tag: str | None = None,
399
+ use_cache: bool = True,
400
+ ) -> dict | None:
401
+ """Compatibility adapter for the shared backend protocol."""
402
+ if not specs:
403
+ return None
404
+ if len(specs) > 1:
405
+ raise ValueError("Binary backend does not support injected packages")
406
+ return self.install_package(
407
+ env_dir,
408
+ specs[0],
409
+ upgrade=upgrade,
410
+ setup_path=setup_path,
411
+ asset_pattern=asset_pattern,
412
+ known_tag=known_tag,
413
+ use_cache=use_cache,
414
+ )
415
+
416
+ def install_package(
417
+ self,
418
+ env_dir: Path,
419
+ spec: str,
420
+ *,
421
+ upgrade: bool = False,
422
+ setup_path: Path | None = None,
423
+ asset_pattern: str | None = None,
424
+ known_tag: str | None = None,
425
+ use_cache: bool = True,
426
+ ) -> dict | None:
427
+ """Resolve, download, extract, and discover one atomic binary tool.
428
+
429
+ *asset_pattern* (when provided) forces the asset selection pattern
430
+ and takes precedence over any value declared in ixt.setup.toml.
431
+
432
+ *known_tag* (when provided) is the latest tag a caller already
433
+ resolved, reused by the fast-path to skip a redundant network HEAD.
434
+
435
+ *use_cache=False* disables reuse of the resolve TTL cache entry. It
436
+ does not disable asset-pattern reuse because that cache stores URL
437
+ shape, not a latest-version decision.
438
+ """
439
+
440
+ log = get_logger("binary")
441
+ repo_spec = parse_spec(spec)
442
+ if repo_spec is None:
443
+ raise BinarySpecError(spec)
444
+
445
+ source = make_source(repo_spec)
446
+ log.debug(f"spec: {repo_spec.owner}/{repo_spec.repo} platform={repo_spec.platform}")
447
+
448
+ cache_dir = _cache_dir_for(self.settings, repo_spec)
449
+
450
+ setup, setup_resolved = self._resolve_setup_before_release(
451
+ source,
452
+ repo_spec,
453
+ cache_dir,
454
+ setup_path,
455
+ upgrade=upgrade,
456
+ asset_pattern=asset_pattern,
457
+ )
458
+ release_pattern = asset_pattern or (
459
+ setup.asset_pattern if setup and setup.asset_pattern else None
460
+ )
461
+ release_pattern_name = setup.name if setup and setup.name else None
462
+
463
+ # Reuse the release already fetched during version resolution (e.g. an
464
+ # upgrade or a dry-run within the TTL window). This skips a second API
465
+ # call — the only option for GitLab, whose asset URLs can't be rebuilt
466
+ # from a pattern like GitHub's.
467
+ release = self._cached_release(spec) if use_cache and not repo_spec.version else None
468
+ if release is not None:
469
+ log.debug(f"release (resolve cache): {release.tag} ({len(release.assets)} assets)")
470
+ else:
471
+ release = self._fetch_release(
472
+ source,
473
+ repo_spec,
474
+ env_dir=env_dir,
475
+ upgrade=upgrade,
476
+ known_tag=known_tag,
477
+ asset_pattern=release_pattern,
478
+ pattern_name=release_pattern_name,
479
+ )
480
+ log.debug(f"release: {release.tag} ({len(release.assets)} assets)")
481
+
482
+ if setup is None and not setup_resolved:
483
+ setup = self._resolve_setup(
484
+ source, repo_spec, cache_dir, setup_path, upgrade=upgrade, ref=release.tag
485
+ )
486
+
487
+ tool_name = setup.name if setup and setup.name else repo_spec.repo
488
+ effective_pattern = asset_pattern or (
489
+ setup.asset_pattern if setup and setup.asset_pattern else None
490
+ )
491
+ if asset_pattern:
492
+ log.debug(f"user-forced asset_pattern: {asset_pattern!r}")
493
+ selection_pattern = _selection_pattern(effective_pattern, release.tag)
494
+ asset = self._select_asset(release, selection_pattern, tool_name, spec)
495
+ log.debug(f"selected asset: {asset.name}")
496
+
497
+ archive_path = self._download_asset(
498
+ source,
499
+ repo_spec,
500
+ asset,
501
+ cache_dir,
502
+ tag=release.tag,
503
+ upgrade=upgrade,
504
+ )
505
+ log.debug(f"archive: {archive_path}")
506
+
507
+ extract_tool_name = tool_name
508
+ if not is_archive(asset.name) and not (setup and setup.name):
509
+ extract_tool_name = _derive_bare_binary_name(asset.name, tool_name, release.tag)
510
+
511
+ result = extract(archive_path, env_dir, tool_name=extract_tool_name)
512
+ log.debug(f"extracted to: {result.root}")
513
+
514
+ heuristics = load_heuristics()
515
+ executables = find_executables(result.root, expose=heuristics.expose, log=log)
516
+ log.debug(f"executables: {[e.name for e in executables]}")
517
+
518
+ binaries = {exe.name: str(exe) for exe in executables}
519
+
520
+ # Learn the asset URL pattern so future upgrades can skip the API.
521
+ # Priority: user CLI override > ixt.setup.toml (author) > derived
522
+ # from the resolved asset name. Only the first two are "forced" —
523
+ # the derived one bakes in OS/arch and is unsafe for cross-machine
524
+ # replay via ixt tool export.
525
+ pattern_forced = effective_pattern is not None
526
+ learned_pattern = effective_pattern or derive_asset_pattern(asset.name, release.tag)
527
+
528
+ _write_metadata(
529
+ env_dir,
530
+ spec=spec,
531
+ tag=release.tag,
532
+ asset_name=asset.name,
533
+ binaries=binaries,
534
+ setup=_build_setup_metadata(setup),
535
+ resolution_source=release.resolution_source,
536
+ )
537
+
538
+ # Persist the pattern globally (IXT_CACHE_HOME metadata) so reinstalls of the
539
+ # same tool — including after an uninstall — skip the API too.
540
+ if learned_pattern and repo_spec.platform == "github":
541
+ from ixt.config import asset_pattern_cache
542
+
543
+ asset_pattern_cache.set_(repo_spec.owner, repo_spec.repo, learned_pattern)
544
+
545
+ return {
546
+ "asset_pattern": learned_pattern,
547
+ "asset_pattern_forced": pattern_forced,
548
+ }
549
+
550
+ def _resolve_setup_before_release(
551
+ self,
552
+ source: ReleaseSource,
553
+ repo_spec: RepoSpec,
554
+ cache_dir: Path,
555
+ setup_path: Path | None,
556
+ *,
557
+ upgrade: bool,
558
+ asset_pattern: str | None,
559
+ ) -> tuple[SetupConfig | None, bool]:
560
+ """Resolve setup early when it can feed a direct release URL.
561
+
562
+ Remote setup files are only fetched early for exact pinned binary tags:
563
+ the raw-file fetch can be pinned to the same tag without consulting the
564
+ release API. Unpinned and partial-version installs keep the normal
565
+ release-first order so setup stays tied to the resolved release tag.
566
+
567
+ Returns ``(setup, resolved)`` where ``resolved`` means the normal
568
+ post-release setup lookup can be skipped, even when no file exists.
569
+ """
570
+ if asset_pattern:
571
+ return None, False
572
+ if setup_path is not None:
573
+ setup = self._resolve_setup(
574
+ source, repo_spec, cache_dir, setup_path, upgrade=upgrade, ref=repo_spec.version
575
+ )
576
+ return setup, True
577
+ if not is_setup_toml_enabled() or not repo_spec.version:
578
+ return None, False
579
+
580
+ from ixt.libs.semver import is_partial_version
581
+
582
+ if is_partial_version(repo_spec.version):
583
+ return None, False
584
+
585
+ for ref in _tag_candidates(repo_spec.version):
586
+ setup = self._resolve_setup(
587
+ source, repo_spec, cache_dir, None, upgrade=upgrade, ref=ref
588
+ )
589
+ if setup is not None:
590
+ return setup, True
591
+ return None, True
592
+
593
+ def _resolve_setup(
594
+ self,
595
+ source: ReleaseSource,
596
+ repo_spec: RepoSpec,
597
+ cache_dir: Path,
598
+ setup_path: Path | None,
599
+ *,
600
+ upgrade: bool,
601
+ ref: str | None = None,
602
+ ) -> SetupConfig | None:
603
+ """Resolve ixt.setup.toml: local override > remote (gated by flag).
604
+
605
+ *ref* pins the remote fetch to a specific revision (tag) so the
606
+ config matches the release being installed.
607
+ """
608
+ log = get_logger("binary")
609
+ if setup_path is not None:
610
+ from ixt.config.setup_toml import load_local_setup_toml
611
+
612
+ setup = load_local_setup_toml(setup_path)
613
+ log.debug(f"using local setup.toml: {setup_path}")
614
+ elif is_setup_toml_enabled():
615
+ setup = self._fetch_setup_toml(source, repo_spec, cache_dir, upgrade=upgrade, ref=ref)
616
+ else:
617
+ setup = None
618
+ if setup:
619
+ log.debug(
620
+ f"ixt.setup.toml: name={setup.name} expose={setup.expose}"
621
+ f" pattern={setup.asset_pattern}"
622
+ )
623
+ return setup
624
+
625
+ def _select_asset(
626
+ self,
627
+ release: Release,
628
+ asset_pattern: str | None,
629
+ tool_name: str,
630
+ main_spec: str,
631
+ ) -> Asset:
632
+ heuristics = load_heuristics()
633
+ asset = select_asset(
634
+ release.assets,
635
+ tool_name,
636
+ _strip_v_prefix(release.tag),
637
+ heuristics=heuristics,
638
+ asset_pattern=asset_pattern,
639
+ verbose=get_verbosity(),
640
+ )
641
+ if asset is None:
642
+ raise AssetSelectionError(
643
+ main_spec,
644
+ release.tag,
645
+ [a.name for a in release.assets],
646
+ )
647
+ return asset
648
+
649
+ def uninstall_package(self, env_dir: Path, package: str) -> None:
650
+ """No-op — binary tools are atomic (uninstall removes the whole env)."""
651
+
652
+ def find_binaries(self, env_dir: Path) -> list[Path]:
653
+ """Discover executables in the extracted environment."""
654
+ meta = _read_metadata(env_dir)
655
+ if meta and meta.get("binaries"):
656
+ # Fast path: use cached binary list from metadata
657
+ existing = _existing_metadata_binaries(meta, env_dir)
658
+ if existing:
659
+ return sorted((Path(p) for p in existing.values()), key=lambda p: p.name)
660
+
661
+ # Slow path: re-scan the environment
662
+ heuristics = load_heuristics()
663
+ return find_executables(env_dir, expose=heuristics.expose, log=get_logger("binary"))
664
+
665
+ def get_package_binaries(self, env_dir: Path, package_name: str) -> dict[str, str]:
666
+ """Return discovered binaries as {name: path}.
667
+
668
+ Priority: setup.toml expose > all discovered executables.
669
+ When setup.toml defines an expose list, only those names are returned.
670
+ """
671
+ meta = _read_metadata(env_dir)
672
+ if not meta or not meta.get("binaries"):
673
+ # Fallback: live scan
674
+ bins = self.find_binaries(env_dir)
675
+ all_bins = {p.name: str(p) for p in bins}
676
+ else:
677
+ all_bins = _existing_metadata_binaries(meta, env_dir)
678
+ if not all_bins:
679
+ bins = self.find_binaries(env_dir)
680
+ all_bins = {p.name: str(p) for p in bins}
681
+
682
+ # Apply setup.toml expose filter if present
683
+ setup = meta.get("setup") if meta else None
684
+ if setup and setup.get("expose"):
685
+ expose_names = set(setup["expose"])
686
+ filtered = {k: v for k, v in all_bins.items() if k in expose_names}
687
+ if filtered:
688
+ return filtered
689
+ from ixt.libs.logger import get_logger
690
+
691
+ get_logger("binary").warn(
692
+ f"setup.toml expose {setup['expose']} matched no binaries "
693
+ f"(available: {list(all_bins)}), falling back to all"
694
+ )
695
+
696
+ return all_bins
697
+
698
+ def installed_version(self, env_dir: Path, package_name: str) -> str | None:
699
+ """Return the installed version from the release tag."""
700
+ meta = _read_metadata(env_dir)
701
+ if meta is None:
702
+ return None
703
+ tag = meta.get("tag", "")
704
+ return _strip_v_prefix(tag) if tag else None
705
+
706
+ def export(self, env_dir: Path) -> list[str]:
707
+ """Return installed spec with pinned version."""
708
+ meta = _read_metadata(env_dir)
709
+ if meta is None:
710
+ return []
711
+ spec = meta.get("spec", "")
712
+ tag = meta.get("tag", "")
713
+ if spec and tag:
714
+ repo_spec = parse_spec(spec)
715
+ if repo_spec:
716
+ return [f"{repo_spec.owner}/{repo_spec.repo}@{tag}"]
717
+ return []
718
+
719
+ # -- Internal helpers ---------------------------------------------------
720
+
721
+ def _cached_release(self, main_spec: str) -> Release | None:
722
+ """Rebuild a Release from a fresh resolve-cache entry that carries assets.
723
+
724
+ Returns None when there is no fresh entry or it has no assets (e.g. the
725
+ GitHub HEAD-redirect path, which caches only the tag). Keyed identically
726
+ to ``resolve.resolve_latest`` so the resolution and the install agree.
727
+ """
728
+ from ixt.core import resolve_cache
729
+
730
+ entry = resolve_cache.get_entry(
731
+ resolve_cache.make_key("binary", main_spec), settings=self.settings
732
+ )
733
+ if not entry:
734
+ return None
735
+ assets = entry.get("assets")
736
+ if not assets:
737
+ return None
738
+ rebuilt = [
739
+ Asset(name=a["name"], url=a["url"], size=a.get("size", 0))
740
+ for a in assets
741
+ if isinstance(a, dict) and a.get("name") and a.get("url")
742
+ ]
743
+ if not rebuilt:
744
+ # Entry present but every asset is malformed/schema-drifted: fall
745
+ # back to the API rather than fail asset selection on an empty list.
746
+ return None
747
+ return Release(tag=entry["version"], assets=rebuilt, resolution_source="resolve-cache")
748
+
749
+ def _fetch_release(
750
+ self,
751
+ source: ReleaseSource,
752
+ repo_spec: RepoSpec,
753
+ *,
754
+ env_dir: Path | None = None,
755
+ upgrade: bool = False,
756
+ known_tag: str | None = None,
757
+ asset_pattern: str | None = None,
758
+ pattern_name: str | None = None,
759
+ ) -> Release:
760
+ """Fetch the target release (by tag, partial semver, or latest).
761
+
762
+ Fast-path: a GitHub repo that has an explicit or cached
763
+ ``asset_pattern`` can synthesize the Release without touching the
764
+ API. Unpinned installs resolve the tag via a plain 302 on
765
+ ``github.com/.../releases/latest``; pinned installs reuse their tag
766
+ directly. Falls back silently on any failure. *known_tag* lets an
767
+ upgrade reuse the tag it already resolved, skipping even that 302.
768
+ """
769
+ if not repo_spec.version:
770
+ # Pattern-based fast-path: consults the global metadata cache and
771
+ # falls back to per-tool metadata. Works on fresh install and
772
+ # upgrade alike — upgrade kept in the signature for callers
773
+ # that still want to force-refresh the cached setup.toml.
774
+ fast = _fast_path_release(
775
+ repo_spec,
776
+ env_dir,
777
+ known_tag=known_tag,
778
+ asset_pattern=asset_pattern,
779
+ pattern_name=pattern_name,
780
+ settings=self.settings,
781
+ )
782
+ if fast is not None:
783
+ return fast
784
+
785
+ if repo_spec.version:
786
+ from ixt.libs.semver import is_partial_version
787
+
788
+ if is_partial_version(repo_spec.version):
789
+ fast = _fast_path_release(
790
+ repo_spec,
791
+ env_dir,
792
+ known_tag=None,
793
+ asset_pattern=asset_pattern,
794
+ pattern_name=pattern_name,
795
+ allow_partial_version=True,
796
+ settings=self.settings,
797
+ )
798
+ if fast is not None:
799
+ return fast
800
+ self._require_github_api(repo_spec)
801
+ return self._fetch_release_partial(source, repo_spec)
802
+
803
+ tag = repo_spec.version
804
+ for direct_tag in _tag_candidates(tag):
805
+ fast = _fast_path_release(
806
+ repo_spec,
807
+ env_dir,
808
+ known_tag=direct_tag,
809
+ asset_pattern=asset_pattern,
810
+ pattern_name=pattern_name,
811
+ invalidate_cache_on_error=False,
812
+ settings=self.settings,
813
+ )
814
+ if fast is not None:
815
+ return fast
816
+
817
+ self._require_github_api(repo_spec)
818
+ # Try exact tag first, then with 'v' prefix.
819
+ try:
820
+ release = source.get_release_by_tag(repo_spec.owner, repo_spec.repo, tag)
821
+ except Exception:
822
+ if not tag.startswith("v"):
823
+ release = source.get_release_by_tag(repo_spec.owner, repo_spec.repo, f"v{tag}")
824
+ release.resolution_source = f"{repo_spec.platform}-api"
825
+ return release
826
+ raise
827
+ release.resolution_source = f"{repo_spec.platform}-api"
828
+ return release
829
+
830
+ self._require_github_api(repo_spec)
831
+ release = source.get_latest_release(repo_spec.owner, repo_spec.repo)
832
+ release.resolution_source = f"{repo_spec.platform}-api"
833
+ return release
834
+
835
+ def _require_github_api(self, repo_spec: RepoSpec) -> None:
836
+ """Fail before touching ``api.github.com`` in strict mode."""
837
+ if repo_spec.platform == "github" and not is_github_api_allowed():
838
+ raise GitHubApiDisabledError(disabled_api_message(repo_spec))
839
+
840
+ def _fetch_release_partial(
841
+ self,
842
+ source: ReleaseSource,
843
+ repo_spec: RepoSpec,
844
+ ) -> Release:
845
+ """Fetch the latest release matching a partial semver (e.g. '14' or '14.1')."""
846
+ from ixt.libs.semver import matches_partial, parse_version
847
+
848
+ releases = source.list_releases(repo_spec.owner, repo_spec.repo)
849
+ partial = repo_spec.version or ""
850
+ candidates = [
851
+ r
852
+ for r in releases
853
+ if not r.prerelease and not r.draft and matches_partial(r.tag, partial)
854
+ ]
855
+ if not candidates:
856
+ raise RuntimeError(
857
+ f"No release matching '{partial}' for {repo_spec.owner}/{repo_spec.repo}"
858
+ )
859
+ # Sort by parsed version descending, pick newest
860
+ candidates.sort(key=lambda r: parse_version(r.tag) or (), reverse=True)
861
+ release = candidates[0]
862
+ release.resolution_source = f"{repo_spec.platform}-api"
863
+ return release
864
+
865
+ def _fetch_setup_toml(
866
+ self,
867
+ source: ReleaseSource,
868
+ repo_spec: RepoSpec,
869
+ cache_dir: Path,
870
+ *,
871
+ upgrade: bool = False,
872
+ ref: str | None = None,
873
+ ) -> SetupConfig | None:
874
+ """Fetch ixt.setup.toml from the repo (cached, silent on 404)."""
875
+ from ixt.config.setup_toml import fetch_setup_toml
876
+
877
+ # On upgrade, invalidate cached setup.toml so we fetch fresh
878
+ if upgrade:
879
+ cached = cache_dir / "setup.toml"
880
+ cached.unlink(missing_ok=True)
881
+
882
+ return fetch_setup_toml(
883
+ source,
884
+ repo_spec.owner,
885
+ repo_spec.repo,
886
+ cache_dir=cache_dir,
887
+ ref=ref,
888
+ )
889
+
890
+ def _download_asset(
891
+ self,
892
+ source: ReleaseSource,
893
+ repo_spec: RepoSpec,
894
+ asset: Asset,
895
+ cache_dir: Path,
896
+ *,
897
+ tag: str,
898
+ upgrade: bool = False,
899
+ ) -> Path:
900
+ """Download an asset, using cache when available."""
901
+ cached = _cached_asset(cache_dir, asset.name)
902
+ if cached is not None and not upgrade:
903
+ self._record_download(repo_spec, asset, cached, tag=tag)
904
+ return cached
905
+
906
+ cache_dir.mkdir(parents=True, exist_ok=True)
907
+ dest = cache_dir / asset.name
908
+ source.download_asset(asset.url, dest)
909
+ self._record_download(repo_spec, asset, dest, tag=tag)
910
+ return dest
911
+
912
+ def _record_download(
913
+ self,
914
+ repo_spec: RepoSpec,
915
+ asset: Asset,
916
+ path: Path,
917
+ *,
918
+ tag: str,
919
+ ) -> None:
920
+ """Index the downloaded binary asset for later cache pruning."""
921
+ from ixt.core.cache import record_download
922
+
923
+ record_download(
924
+ settings=self.settings,
925
+ kind="binary-asset",
926
+ platform=repo_spec.platform,
927
+ host=repo_spec.host,
928
+ owner=repo_spec.owner,
929
+ repo=repo_spec.repo,
930
+ tag=tag,
931
+ asset_name=asset.name,
932
+ asset_url=asset.url,
933
+ asset_size=asset.size,
934
+ path=path,
935
+ )