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/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}")
|