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/backend.py ADDED
@@ -0,0 +1,187 @@
1
+ """Backend protocol and auto-detection for tool installation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from ixt.config.registry import lookup_spec
10
+
11
+
12
+ class BackendType(Enum):
13
+ """Supported tool ecosystems."""
14
+
15
+ PYTHON = "python"
16
+ NODE = "node"
17
+ BINARY = "binary"
18
+
19
+
20
+ @runtime_checkable
21
+ class Backend(Protocol):
22
+ """Interface every backend must implement."""
23
+
24
+ backend_type: BackendType
25
+
26
+ def env_exists(self, env_dir: Path) -> bool:
27
+ """Check whether the isolated environment already exists."""
28
+ ...
29
+
30
+ def create_env(self, env_dir: Path) -> bool:
31
+ """Create the isolated environment. Return True if newly created."""
32
+ ...
33
+
34
+ def install_packages(
35
+ self, env_dir: Path, specs: list[str], *, upgrade: bool = False
36
+ ) -> dict | None:
37
+ """Install packages into the environment.
38
+
39
+ May return a dict of backend-specific metadata to merge into the
40
+ ToolRecord (e.g. ``asset_pattern`` for the binary backend).
41
+ """
42
+ ...
43
+
44
+ def uninstall_package(self, env_dir: Path, package: str) -> None:
45
+ """Remove a package from the environment."""
46
+ ...
47
+
48
+ def find_binaries(self, env_dir: Path) -> list[Path]:
49
+ """Discover all executables exposed by the environment."""
50
+ ...
51
+
52
+ def get_package_binaries(self, env_dir: Path, package_name: str) -> dict[str, str]:
53
+ """Return binaries declared by *package_name*.
54
+
55
+ Returns ``{binary_name: entry_point_or_path}``.
56
+ For Python this reads ``console_scripts``; for Node it reads
57
+ the ``bin`` field in ``package.json``.
58
+ """
59
+ ...
60
+
61
+ def installed_version(self, env_dir: Path, package_name: str) -> str | None:
62
+ """Return the installed version of *package_name*, or ``None``."""
63
+ ...
64
+
65
+ def export(self, env_dir: Path) -> list[str]:
66
+ """List every installed package with pinned version."""
67
+ ...
68
+
69
+
70
+ # Namespace prefixes that force a specific backend: @prefix:spec
71
+ _NAMESPACE_PREFIXES: dict[str, BackendType] = {
72
+ "pypi": BackendType.PYTHON,
73
+ "npm": BackendType.NODE,
74
+ "gh": BackendType.BINARY,
75
+ "gl": BackendType.BINARY,
76
+ }
77
+
78
+ # Forge prefixes that normalize to a default host when the spec is owner/repo.
79
+ _FORGE_DEFAULT_HOST: dict[str, str] = {
80
+ "gh": "github.com",
81
+ "gl": "gitlab.com",
82
+ }
83
+
84
+
85
+ def _ensure_forge_host(spec: str, default_host: str) -> str:
86
+ """Prefix *default_host* to ``owner/repo`` specs lacking a host.
87
+
88
+ Leaves ``host/owner/repo`` (first segment contains a dot) and short names
89
+ without ``/`` (short names go through registry lookup) unchanged.
90
+ """
91
+ if "/" not in spec:
92
+ return spec
93
+ first = spec.split("/", 1)[0]
94
+ if "." in first:
95
+ return spec
96
+ return f"{default_host}/{spec}"
97
+
98
+
99
+ def strip_protocol(spec: str) -> tuple[BackendType | None, str]:
100
+ """Strip a namespace prefix from *spec* and return ``(forced_backend, clean_spec)``.
101
+
102
+ Recognized prefixes: ``@pypi:``, ``@npm:``, ``@gh:``, ``@gl:``.
103
+ For forge prefixes (``@gh:``/``@gl:``), an ``owner/repo`` spec is normalized
104
+ to ``host/owner/repo`` so the binary backend routes to the right platform.
105
+ If no prefix matches, returns ``(None, spec)`` unchanged.
106
+
107
+ Raises:
108
+ ValueError: If spec has ``@prefix:`` form but prefix is unknown,
109
+ or if spec looks like a namespace prefix but is missing the
110
+ leading ``@`` (e.g. ``gl:owner/repo`` should be ``@gl:owner/repo``).
111
+ """
112
+ s = spec.strip()
113
+ if s.startswith("@") and ":" in s:
114
+ prefix, _, rest = s[1:].partition(":")
115
+ if prefix in _NAMESPACE_PREFIXES:
116
+ default_host = _FORGE_DEFAULT_HOST.get(prefix)
117
+ if default_host is not None:
118
+ rest = _ensure_forge_host(rest, default_host)
119
+ return _NAMESPACE_PREFIXES[prefix], rest
120
+ known = ", ".join(f"@{p}:" for p in _NAMESPACE_PREFIXES)
121
+ raise ValueError(f"Unknown namespace '@{prefix}:'. Valid: {known}")
122
+ # Catch the missing-@ typo: ``gl:owner/repo`` should be ``@gl:owner/repo``.
123
+ # Only short alphanumeric prefixes (≤6 chars) qualify, so we don't trip on
124
+ # legitimate ``foo:bar``-style URLs or specs.
125
+ if ":" in s and not s.startswith("@"):
126
+ head, _, rest = s.partition(":")
127
+ if head and head.isalnum() and len(head) <= 6 and head in _NAMESPACE_PREFIXES:
128
+ raise ValueError(
129
+ f"Spec '{spec}' looks like a namespace shortcut. "
130
+ f"Did you mean '@{head}:{rest}'? Prefixes need a leading '@'."
131
+ )
132
+ return None, s
133
+
134
+
135
+ def detect_backend(spec: str) -> BackendType:
136
+ """Auto-detect backend type from a package specification.
137
+
138
+ Supports explicit namespace prefixes:
139
+
140
+ * ``@pypi:ruff`` -> Python
141
+ * ``@npm:typescript`` -> Node
142
+ * ``@gh:BurntSushi/ripgrep`` -> Binary (GitHub)
143
+ * ``@gl:gitlab-org/cli`` -> Binary (GitLab)
144
+
145
+ Without prefix, auto-detection applies:
146
+
147
+ * ``@scope/pkg`` -> Node
148
+ * ``owner/repo`` -> Binary (GitHub Releases)
149
+ * simple name -> registry lookup, then fallback Python (PyPI)
150
+ """
151
+ forced, clean = strip_protocol(spec)
152
+ if forced is not None:
153
+ return forced
154
+
155
+ name = clean
156
+ for sep in ("[", ">", "<", "=", "!", "~"):
157
+ name = name.split(sep, 1)[0]
158
+ name = name.strip()
159
+
160
+ if name.startswith("@") and "/" in name:
161
+ return BackendType.NODE
162
+ if "/" in name:
163
+ return BackendType.BINARY
164
+
165
+ # Registry lookup for simple names (e.g. "ripgrep" -> "@gh:BurntSushi/ripgrep")
166
+ registry_spec = lookup_spec(name)
167
+ if registry_spec is not None:
168
+ return detect_backend(registry_spec)
169
+
170
+ return BackendType.PYTHON
171
+
172
+
173
+ def get_backend(backend_type: BackendType, **kwargs) -> Backend:
174
+ """Instantiate the backend for *backend_type*."""
175
+ if backend_type == BackendType.PYTHON:
176
+ from ixt.backends.python import PythonBackend
177
+
178
+ return PythonBackend(**kwargs)
179
+ if backend_type == BackendType.NODE:
180
+ from ixt.backends.node import NodeBackend
181
+
182
+ return NodeBackend(**kwargs)
183
+ if backend_type == BackendType.BINARY:
184
+ from ixt.backends.binary import BinaryBackend
185
+
186
+ return BinaryBackend(**kwargs)
187
+ raise NotImplementedError(f"Backend '{backend_type.value}' is not yet implemented")
ixt/core/bootstrap.py ADDED
@@ -0,0 +1,410 @@
1
+ """Runtime bootstrap — ensure required runtimes are available.
2
+
3
+ Finds, verifies, and downloads ``uv`` and ``bun`` so that ixt can manage
4
+ Python and Node tools without requiring the user to pre-install anything
5
+ beyond Python itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import stat
13
+ import tarfile
14
+ import tempfile
15
+ import zipfile
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Literal
20
+ from urllib.parse import unquote, urlparse
21
+
22
+ from ixt.config.settings import Settings, get_settings
23
+ from ixt.libs.semver import parse_version
24
+ from ixt.libs.shell import ShellError, command_exists, shell_run_output
25
+ from ixt.net.http import HttpError, download_file
26
+ from ixt.platform import OS, Arch, get_arch, get_os, is_windows
27
+
28
+
29
+ class BootstrapError(RuntimeError):
30
+ """Raised when runtime bootstrap fails."""
31
+
32
+
33
+ RuntimeName = Literal["uv", "bun"]
34
+
35
+
36
+ # ===========================================================================
37
+ # Generic runtime bootstrap primitives
38
+ # ===========================================================================
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class _RuntimeSpec:
43
+ """Parameters describing how to find, verify, and download a runtime."""
44
+
45
+ name: str
46
+ min_version: tuple[int, ...]
47
+ parse_version: Callable[[str], tuple[int, ...]]
48
+ get_download: Callable[[], tuple[str, str]] # returns (url, archive-ext)
49
+ extract: Callable[[Path, Path, str], None] # (archive, dest, ext)
50
+ runtime_path: Callable[[Settings], Path]
51
+
52
+
53
+ def _check_version(spec: _RuntimeSpec, path: str) -> bool:
54
+ """Return *True* if the runtime at *path* meets ``spec.min_version``."""
55
+ try:
56
+ output = shell_run_output([path, "--version"])
57
+ return spec.parse_version(output) >= spec.min_version
58
+ except (ShellError, BootstrapError):
59
+ return False
60
+
61
+
62
+ def _archive_cache_path(settings: Settings, spec: _RuntimeSpec, url: str, ext: str) -> Path:
63
+ name = Path(unquote(urlparse(url).path)).name or f"{spec.name}.{ext}"
64
+ return settings.downloads_dir / "runtimes" / spec.name / name
65
+
66
+
67
+ def _download_archive(url: str, archive: Path, settings: Settings) -> None:
68
+ archive.parent.mkdir(parents=True, exist_ok=True)
69
+ settings.tmp_dir.mkdir(parents=True, exist_ok=True)
70
+ fd, tmp_name = tempfile.mkstemp(
71
+ prefix=f"{archive.name}.",
72
+ suffix=".tmp",
73
+ dir=settings.tmp_dir,
74
+ )
75
+ os.close(fd)
76
+ tmp = Path(tmp_name)
77
+ try:
78
+ download_file(url, tmp, headers={"User-Agent": "ixt-bootstrap/1"})
79
+ tmp.replace(archive)
80
+ except (HttpError, OSError) as exc:
81
+ tmp.unlink(missing_ok=True)
82
+ raise BootstrapError(f"Failed to download {url}: {exc}") from exc
83
+
84
+
85
+ def _download_runtime(
86
+ spec: _RuntimeSpec,
87
+ target_path: Path,
88
+ *,
89
+ settings: Settings | None = None,
90
+ force: bool = False,
91
+ ) -> None:
92
+ """Download, extract, and verify the runtime described by *spec*."""
93
+ s = settings or get_settings()
94
+ url, ext = spec.get_download()
95
+ archive = _archive_cache_path(s, spec, url, ext)
96
+ target_path.parent.mkdir(parents=True, exist_ok=True)
97
+
98
+ if force or not archive.exists() or archive.stat().st_size == 0:
99
+ _download_archive(url, archive, s)
100
+
101
+ try:
102
+ spec.extract(archive, target_path, ext)
103
+ except (BootstrapError, OSError, tarfile.TarError, zipfile.BadZipFile):
104
+ if force:
105
+ raise
106
+ archive.unlink(missing_ok=True)
107
+ _download_archive(url, archive, s)
108
+ spec.extract(archive, target_path, ext)
109
+
110
+ if not _check_version(spec, str(target_path)):
111
+ target_path.unlink(missing_ok=True)
112
+ if force:
113
+ raise BootstrapError(f"Downloaded {spec.name} binary failed version check")
114
+ archive.unlink(missing_ok=True)
115
+ _download_archive(url, archive, s)
116
+ spec.extract(archive, target_path, ext)
117
+ if not _check_version(spec, str(target_path)):
118
+ target_path.unlink(missing_ok=True)
119
+ raise BootstrapError(f"Downloaded {spec.name} binary failed version check")
120
+
121
+
122
+ def _find_runtime(
123
+ spec: _RuntimeSpec,
124
+ settings: Settings | None,
125
+ check_fn: Callable[[str], bool],
126
+ ) -> str | None:
127
+ """Find a usable runtime matching *spec* without downloading anything.
128
+
129
+ Search order:
130
+ 1. Bootstrapped binary at ``spec.runtime_path(settings)``
131
+ 2. System binary on ``PATH``
132
+
133
+ *check_fn* is passed by the caller so tests can ``patch()`` the
134
+ per-runtime wrapper (``_check_uv_version`` / ``_check_bun_version``).
135
+ """
136
+ s = settings or get_settings()
137
+
138
+ rt = spec.runtime_path(s)
139
+ if rt.exists() and check_fn(str(rt)):
140
+ return str(rt)
141
+
142
+ if command_exists(spec.name) and check_fn(spec.name):
143
+ return spec.name
144
+
145
+ return None
146
+
147
+
148
+ def _ensure_runtime(
149
+ spec: _RuntimeSpec,
150
+ settings: Settings | None,
151
+ find_fn: Callable[[Settings | None], str | None],
152
+ download_fn: Callable[..., None],
153
+ ) -> str:
154
+ """Return a working runtime path, downloading via *download_fn* if needed.
155
+
156
+ *find_fn* and *download_fn* are looked up at call time (passed by name,
157
+ not by value), so tests can ``patch()`` the module-level symbols.
158
+ """
159
+ s = settings or get_settings()
160
+ found = find_fn(s)
161
+ if found is not None:
162
+ return found
163
+
164
+ from ixt.libs.logger import get_logger
165
+
166
+ get_logger("bootstrap").info(f"Bootstrapping {spec.name} runtime...")
167
+ target = spec.runtime_path(s)
168
+ download_fn(target, settings=s)
169
+ return str(target)
170
+
171
+
172
+ # ===========================================================================
173
+ # uv bootstrap
174
+ # ===========================================================================
175
+
176
+ # Minimum uv version required (0.4.0 stabilised uv pip / uv venv).
177
+ MIN_UV_VERSION: tuple[int, ...] = (0, 4, 0)
178
+
179
+ # GitHub release archive URL pattern.
180
+ _UV_RELEASE_URL = "https://github.com/astral-sh/uv/releases/latest/download/uv-{target}.{ext}"
181
+
182
+ # Platform → uv release target name.
183
+ _UV_TARGETS: dict[tuple[OS, Arch], str] = {
184
+ (OS.LINUX, Arch.X86_64): "x86_64-unknown-linux-musl",
185
+ (OS.LINUX, Arch.ARM64): "aarch64-unknown-linux-musl",
186
+ (OS.LINUX, Arch.ARMV7): "armv7-unknown-linux-musleabihf",
187
+ (OS.MACOS, Arch.X86_64): "x86_64-apple-darwin",
188
+ (OS.MACOS, Arch.ARM64): "aarch64-apple-darwin",
189
+ (OS.WINDOWS, Arch.X86_64): "x86_64-pc-windows-msvc",
190
+ (OS.WINDOWS, Arch.ARM64): "aarch64-pc-windows-msvc",
191
+ }
192
+
193
+
194
+ def _parse_uv_version(version_str: str) -> tuple[int, ...]:
195
+ """Parse ``'uv 0.5.1 (abc123)'`` or ``'0.5.1'`` into ``(0, 5, 1)``."""
196
+ text = version_str.strip().removeprefix("uv ")
197
+ result = parse_version(text)
198
+ if result is None:
199
+ raise BootstrapError(f"Cannot parse uv version: {version_str!r}")
200
+ return result
201
+
202
+
203
+ def _get_uv_download_url() -> tuple[str, str]:
204
+ """Return ``(url, archive_extension)`` for the current platform."""
205
+ key = (get_os(), get_arch())
206
+ target = _UV_TARGETS.get(key)
207
+ if target is None:
208
+ raise BootstrapError(f"No uv binary available for {get_os().value}/{get_arch().value}")
209
+ ext = "zip" if is_windows() else "tar.gz"
210
+ url = _UV_RELEASE_URL.format(target=target, ext=ext)
211
+ return url, ext
212
+
213
+
214
+ def _extract_uv(archive_path: Path, dest: Path, ext: str) -> None:
215
+ """Extract the ``uv`` binary from *archive_path* into *dest*."""
216
+ binary_name = "uv.exe" if is_windows() else "uv"
217
+ dest.parent.mkdir(parents=True, exist_ok=True)
218
+
219
+ if ext == "tar.gz":
220
+ with tarfile.open(archive_path, "r:gz") as tar:
221
+ for member in tar.getmembers():
222
+ if member.name.endswith(f"/{binary_name}") or member.name == binary_name:
223
+ reader = tar.extractfile(member)
224
+ if reader is None:
225
+ continue
226
+ with dest.open("wb") as f:
227
+ shutil.copyfileobj(reader, f)
228
+ dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
229
+ return
230
+ elif ext == "zip":
231
+ with zipfile.ZipFile(archive_path) as zf:
232
+ for name in zf.namelist():
233
+ if name.endswith(f"/{binary_name}") or name == binary_name:
234
+ with zf.open(name) as src, dest.open("wb") as f:
235
+ shutil.copyfileobj(src, f)
236
+ return
237
+
238
+ raise BootstrapError(f"Could not find {binary_name} in archive {archive_path}")
239
+
240
+
241
+ _UV_SPEC = _RuntimeSpec(
242
+ name="uv",
243
+ min_version=MIN_UV_VERSION,
244
+ parse_version=_parse_uv_version,
245
+ get_download=_get_uv_download_url,
246
+ extract=_extract_uv,
247
+ runtime_path=lambda s: s.uv_runtime,
248
+ )
249
+
250
+
251
+ def _check_uv_version(uv_path: str) -> bool:
252
+ """Return *True* if the ``uv`` at *uv_path* meets the minimum version."""
253
+ return _check_version(_UV_SPEC, uv_path)
254
+
255
+
256
+ def _download_uv(
257
+ target_path: Path,
258
+ *,
259
+ settings: Settings | None = None,
260
+ force: bool = False,
261
+ ) -> None:
262
+ """Download and install the ``uv`` binary to *target_path*."""
263
+ _download_runtime(_UV_SPEC, target_path, settings=settings, force=force)
264
+
265
+
266
+ def find_uv(settings: Settings | None = None) -> str | None:
267
+ """Find a usable ``uv`` binary without downloading anything.
268
+
269
+ Search order:
270
+ 1. Bootstrapped ``uv`` at ``$IXT_HOME/installed/runtimes/uv``
271
+ 2. System ``uv`` in ``PATH``
272
+
273
+ Returns:
274
+ Path string to a working ``uv``, or *None*.
275
+ """
276
+ return _find_runtime(_UV_SPEC, settings, _check_uv_version)
277
+
278
+
279
+ def ensure_uv(settings: Settings | None = None) -> str:
280
+ """Ensure ``uv`` is available, downloading it if necessary.
281
+
282
+ This is the main entry point — idempotent and lazy.
283
+
284
+ Returns:
285
+ Path string to a working ``uv``.
286
+
287
+ Raises:
288
+ BootstrapError: If ``uv`` cannot be found or downloaded.
289
+ """
290
+ return _ensure_runtime(_UV_SPEC, settings, find_uv, _download_uv)
291
+
292
+
293
+ # ===========================================================================
294
+ # Bun bootstrap
295
+ # ===========================================================================
296
+
297
+ # Minimum bun version required (1.0.0 stabilised bun add / bun run).
298
+ MIN_BUN_VERSION: tuple[int, ...] = (1, 0, 0)
299
+
300
+ # GitHub release archive URL pattern — bun ships as zip on all platforms.
301
+ _BUN_RELEASE_URL = "https://github.com/oven-sh/bun/releases/latest/download/{target}.zip"
302
+
303
+ # Platform → bun release target name.
304
+ _BUN_TARGETS: dict[tuple[OS, Arch], str] = {
305
+ (OS.LINUX, Arch.X86_64): "bun-linux-x64",
306
+ (OS.LINUX, Arch.ARM64): "bun-linux-aarch64",
307
+ (OS.MACOS, Arch.X86_64): "bun-darwin-x64",
308
+ (OS.MACOS, Arch.ARM64): "bun-darwin-aarch64",
309
+ (OS.WINDOWS, Arch.X86_64): "bun-windows-x64",
310
+ }
311
+
312
+
313
+ def _parse_bun_version(version_str: str) -> tuple[int, ...]:
314
+ """Parse ``'1.1.34'`` or ``'bun 1.1.34'`` into ``(1, 1, 34)``."""
315
+ text = version_str.strip()
316
+ if text.lower().startswith("bun "):
317
+ text = text[4:]
318
+ result = parse_version(text)
319
+ if result is None:
320
+ raise BootstrapError(f"Cannot parse bun version: {version_str!r}")
321
+ return result
322
+
323
+
324
+ def _get_bun_download_url() -> str:
325
+ """Return the download URL for the current platform."""
326
+ key = (get_os(), get_arch())
327
+ target = _BUN_TARGETS.get(key)
328
+ if target is None:
329
+ raise BootstrapError(f"No bun binary available for {get_os().value}/{get_arch().value}")
330
+ return _BUN_RELEASE_URL.format(target=target)
331
+
332
+
333
+ def _extract_bun(archive_path: Path, dest: Path) -> None:
334
+ """Extract the ``bun`` binary from a zip archive into *dest*."""
335
+ binary_name = "bun.exe" if is_windows() else "bun"
336
+ dest.parent.mkdir(parents=True, exist_ok=True)
337
+
338
+ with zipfile.ZipFile(archive_path) as zf:
339
+ for name in zf.namelist():
340
+ if name.endswith(f"/{binary_name}") or name == binary_name:
341
+ with zf.open(name) as src, dest.open("wb") as f:
342
+ shutil.copyfileobj(src, f)
343
+ if not is_windows():
344
+ dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
345
+ return
346
+
347
+ raise BootstrapError(f"Could not find {binary_name} in archive {archive_path}")
348
+
349
+
350
+ _BUN_SPEC = _RuntimeSpec(
351
+ name="bun",
352
+ min_version=MIN_BUN_VERSION,
353
+ parse_version=_parse_bun_version,
354
+ get_download=lambda: (_get_bun_download_url(), "zip"),
355
+ extract=lambda archive, dest, _ext: _extract_bun(archive, dest),
356
+ runtime_path=lambda s: s.bun_runtime,
357
+ )
358
+
359
+
360
+ def _check_bun_version(bun_path: str) -> bool:
361
+ """Return *True* if ``bun`` at *bun_path* meets the minimum version."""
362
+ return _check_version(_BUN_SPEC, bun_path)
363
+
364
+
365
+ def _download_bun(
366
+ target_path: Path,
367
+ *,
368
+ settings: Settings | None = None,
369
+ force: bool = False,
370
+ ) -> None:
371
+ """Download and install the ``bun`` binary to *target_path*."""
372
+ _download_runtime(_BUN_SPEC, target_path, settings=settings, force=force)
373
+
374
+
375
+ def find_bun(settings: Settings | None = None) -> str | None:
376
+ """Find a usable ``bun`` binary without downloading anything.
377
+
378
+ Search order:
379
+ 1. Bootstrapped ``bun`` at ``$IXT_HOME/installed/runtimes/bun``
380
+ 2. System ``bun`` in ``PATH``
381
+ """
382
+ return _find_runtime(_BUN_SPEC, settings, _check_bun_version)
383
+
384
+
385
+ def ensure_bun(settings: Settings | None = None) -> str:
386
+ """Ensure ``bun`` is available, downloading it if necessary.
387
+
388
+ Returns:
389
+ Path string to a working ``bun``.
390
+
391
+ Raises:
392
+ BootstrapError: If ``bun`` cannot be found or downloaded.
393
+ """
394
+ return _ensure_runtime(_BUN_SPEC, settings, find_bun, _download_bun)
395
+
396
+
397
+ def upgrade_runtime(name: RuntimeName, *, settings: Settings | None = None) -> str:
398
+ """Download or replace an ixt-managed runtime.
399
+
400
+ This intentionally writes to ``$IXT_HOME/installed/runtimes`` even when a
401
+ usable system runtime exists, so users can move back to the managed path.
402
+ """
403
+ s = settings or get_settings()
404
+ if name == "uv":
405
+ _download_uv(s.uv_runtime, settings=s, force=True)
406
+ return str(s.uv_runtime)
407
+ if name == "bun":
408
+ _download_bun(s.bun_runtime, settings=s, force=True)
409
+ return str(s.bun_runtime)
410
+ raise BootstrapError(f"Unsupported runtime: {name}")