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/install.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Backend-agnostic tool installation and removal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ixt.backends.binary import SetupMetadata
|
|
12
|
+
|
|
13
|
+
from ixt.config.models import ToolRecord
|
|
14
|
+
from ixt.config.settings import Settings, get_settings
|
|
15
|
+
from ixt.core.backend import BackendType, detect_backend, get_backend, strip_protocol
|
|
16
|
+
from ixt.core.expose import expose_tool, unexpose_tool
|
|
17
|
+
from ixt.core.identity import (
|
|
18
|
+
display_spec_for_record,
|
|
19
|
+
id_prefixes,
|
|
20
|
+
is_id_selector,
|
|
21
|
+
make_tool_id,
|
|
22
|
+
parse_id_selector,
|
|
23
|
+
validate_slot,
|
|
24
|
+
)
|
|
25
|
+
from ixt.libs.constants import EXPOSE_MAIN
|
|
26
|
+
from ixt.libs.req_spec import parse_requirement
|
|
27
|
+
from ixt.net.source import parse_spec, strip_version_suffix
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class InstallPlan:
|
|
32
|
+
"""What ``install_tool`` would do for a given spec, without executing."""
|
|
33
|
+
|
|
34
|
+
backend: BackendType
|
|
35
|
+
original_spec: str
|
|
36
|
+
resolved_spec: str
|
|
37
|
+
pkg_name: str
|
|
38
|
+
tool_name: str
|
|
39
|
+
env_dir: Path
|
|
40
|
+
expose_rules: list[str]
|
|
41
|
+
inject: list[str]
|
|
42
|
+
already_installed: bool
|
|
43
|
+
setup_path: Path | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_binary_hooks(env_dir: Path) -> SetupMetadata | None:
|
|
47
|
+
"""Read hook config from binary backend metadata, if present."""
|
|
48
|
+
meta_path = env_dir / "binary_metadata.json"
|
|
49
|
+
if not meta_path.exists():
|
|
50
|
+
return None
|
|
51
|
+
with meta_path.open(encoding="utf-8") as f:
|
|
52
|
+
meta = json.load(f)
|
|
53
|
+
return meta.get("setup")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _first_bin_path(exposed_bins: dict[str, str]) -> str | None:
|
|
57
|
+
"""Return the path of the first exposed binary (used as ``{bin}``)."""
|
|
58
|
+
if not exposed_bins:
|
|
59
|
+
return None
|
|
60
|
+
return next(iter(exposed_bins.values()))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run_hook(env_dir: Path, exposed_bins: dict[str, str], hook_key: str) -> None:
|
|
64
|
+
"""Run a lifecycle hook from binary metadata if defined."""
|
|
65
|
+
setup = _read_binary_hooks(env_dir)
|
|
66
|
+
if not setup or hook_key not in setup:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
from ixt.config.setup_toml import HookConfig
|
|
70
|
+
from ixt.core import hooks as hook_mod
|
|
71
|
+
from ixt.libs.logger import get_logger
|
|
72
|
+
|
|
73
|
+
log = get_logger("hooks")
|
|
74
|
+
hook_data = setup[hook_key]
|
|
75
|
+
hook = HookConfig(run=hook_data.get("run"), check=hook_data.get("check"))
|
|
76
|
+
bin_path = _first_bin_path(exposed_bins)
|
|
77
|
+
if not bin_path:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
runner = getattr(hook_mod, f"run_{hook_key}")
|
|
81
|
+
try:
|
|
82
|
+
runner(hook, bin_path=bin_path, env_dir=env_dir)
|
|
83
|
+
except hook_mod.HookValidationError as e:
|
|
84
|
+
log.warn(f"{hook_key} hook rejected: {e}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ToolAlreadyInstalledError(RuntimeError):
|
|
88
|
+
"""Raised when a tool is already installed and ``force`` is False."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, tool_name: str, env_dir: Path):
|
|
91
|
+
self.tool_name = tool_name
|
|
92
|
+
self.env_dir = env_dir
|
|
93
|
+
super().__init__(f"Tool '{tool_name}' is already installed in {env_dir}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ToolNotInstalledError(LookupError):
|
|
97
|
+
"""Raised when an operation targets a tool that isn't installed."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, name: str):
|
|
100
|
+
self.name = name
|
|
101
|
+
super().__init__(f"Tool '{name}' is not installed")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _reject_binary_inject(bt: BackendType, inject: list[str]) -> None:
|
|
105
|
+
if bt == BackendType.BINARY and inject:
|
|
106
|
+
raise ValueError("Binary backend does not support injected packages")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate_env_dir(env_dir: Path, envs_root: Path) -> Path:
|
|
110
|
+
"""Ensure *env_dir* is safely under *envs_root* (anti-traversal)."""
|
|
111
|
+
resolved = env_dir.expanduser().resolve(strict=False)
|
|
112
|
+
root = envs_root.expanduser().resolve(strict=False)
|
|
113
|
+
if resolved != root and root not in resolved.parents:
|
|
114
|
+
raise ValueError(f"Invalid env path outside {root}: {resolved}")
|
|
115
|
+
return resolved
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _remove_env_dir(env_dir: Path, settings: Settings, *, ignore_errors: bool = False) -> None:
|
|
119
|
+
"""Remove a tool env only after revalidating it stays under ``envs_dir``."""
|
|
120
|
+
safe_env_dir = env_dir.expanduser().resolve(strict=False)
|
|
121
|
+
envs_root = settings.envs_dir.expanduser().resolve(strict=False)
|
|
122
|
+
try:
|
|
123
|
+
safe_env_dir.relative_to(envs_root)
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise ValueError(f"Invalid env path outside {envs_root}: {safe_env_dir}") from exc
|
|
126
|
+
if safe_env_dir == envs_root:
|
|
127
|
+
raise ValueError(f"Refusing to remove env root: {envs_root}")
|
|
128
|
+
try:
|
|
129
|
+
_remove_tree_no_follow(safe_env_dir)
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
if not ignore_errors:
|
|
132
|
+
raise
|
|
133
|
+
except OSError:
|
|
134
|
+
if not ignore_errors:
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _remove_tree_no_follow(path: Path) -> None:
|
|
139
|
+
"""Recursively remove *path* without following symlinks."""
|
|
140
|
+
if path.is_symlink() or path.is_file():
|
|
141
|
+
path.unlink()
|
|
142
|
+
return
|
|
143
|
+
for child in path.iterdir():
|
|
144
|
+
if child.is_symlink() or child.is_file():
|
|
145
|
+
child.unlink()
|
|
146
|
+
elif child.is_dir():
|
|
147
|
+
_remove_tree_no_follow(child)
|
|
148
|
+
path.rmdir()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _make_tool_name(bt: BackendType, pkg_name: str, clean_spec: str) -> str:
|
|
152
|
+
"""Compute the namespaced tool identifier from backend + spec."""
|
|
153
|
+
return make_tool_id(bt, pkg_name, clean_spec)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def plan_install(
|
|
157
|
+
package_spec: str,
|
|
158
|
+
*,
|
|
159
|
+
inject: list[str] | None = None,
|
|
160
|
+
expose_rules: list[str] | None = None,
|
|
161
|
+
slot: str | None = None,
|
|
162
|
+
backend_type: BackendType | None = None,
|
|
163
|
+
setup_path: Path | None = None,
|
|
164
|
+
settings: Settings | None = None,
|
|
165
|
+
bare: bool = False,
|
|
166
|
+
) -> InstallPlan:
|
|
167
|
+
"""Resolve what ``install_tool`` would do, without touching disk or network.
|
|
168
|
+
|
|
169
|
+
Performs spec parsing, backend detection, registry lookup (local file),
|
|
170
|
+
and env dir resolution. Does not create directories, fetch packages,
|
|
171
|
+
or query any remote registry.
|
|
172
|
+
|
|
173
|
+
``bare=True`` plans an install without any exposed shim; it is sugar for
|
|
174
|
+
``expose_rules=[]`` and takes precedence when both are given.
|
|
175
|
+
"""
|
|
176
|
+
settings = settings or get_settings()
|
|
177
|
+
if bare:
|
|
178
|
+
expose_rules = []
|
|
179
|
+
|
|
180
|
+
# Strip namespace prefix (e.g. @pypi:, @gh:) before further processing.
|
|
181
|
+
_forced_bt, clean_spec = strip_protocol(package_spec)
|
|
182
|
+
bt = backend_type or _forced_bt or detect_backend(clean_spec)
|
|
183
|
+
resolved_inject = list(inject or [])
|
|
184
|
+
|
|
185
|
+
# Resolve short names via registry (e.g. "ripgrep" -> "BurntSushi/ripgrep")
|
|
186
|
+
if "/" not in clean_spec and not clean_spec.startswith("@"):
|
|
187
|
+
from ixt.config.registry import lookup
|
|
188
|
+
|
|
189
|
+
entry = lookup(clean_spec)
|
|
190
|
+
if entry is not None:
|
|
191
|
+
_reg_bt, resolved = strip_protocol(entry.spec)
|
|
192
|
+
clean_spec = resolved
|
|
193
|
+
|
|
194
|
+
_reject_binary_inject(bt, resolved_inject)
|
|
195
|
+
|
|
196
|
+
# Extract tool name: each backend uses its own spec format.
|
|
197
|
+
if bt == BackendType.BINARY:
|
|
198
|
+
repo_spec = parse_spec(clean_spec)
|
|
199
|
+
pkg_name = repo_spec.repo if repo_spec else clean_spec.split("/")[-1].split("@")[0]
|
|
200
|
+
elif bt == BackendType.NODE:
|
|
201
|
+
from ixt.backends.node import parse_npm_spec
|
|
202
|
+
|
|
203
|
+
pkg_name, _ver = parse_npm_spec(clean_spec)
|
|
204
|
+
else:
|
|
205
|
+
parsed = parse_requirement(clean_spec)
|
|
206
|
+
pkg_name = parsed.name
|
|
207
|
+
|
|
208
|
+
tool_name = make_tool_id(bt, pkg_name, clean_spec, slot=slot)
|
|
209
|
+
env_dir = settings.get_tool_env_dir(tool_name)
|
|
210
|
+
env_dir = _validate_env_dir(env_dir, settings.envs_dir)
|
|
211
|
+
|
|
212
|
+
resolved_expose = [EXPOSE_MAIN] if expose_rules is None else list(expose_rules)
|
|
213
|
+
|
|
214
|
+
return InstallPlan(
|
|
215
|
+
backend=bt,
|
|
216
|
+
original_spec=package_spec,
|
|
217
|
+
resolved_spec=clean_spec,
|
|
218
|
+
pkg_name=pkg_name,
|
|
219
|
+
tool_name=tool_name,
|
|
220
|
+
env_dir=env_dir,
|
|
221
|
+
expose_rules=resolved_expose,
|
|
222
|
+
inject=resolved_inject,
|
|
223
|
+
already_installed=env_dir.exists(),
|
|
224
|
+
setup_path=setup_path,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def display_tool_name(record: ToolRecord, settings: Settings | None = None) -> str:
|
|
229
|
+
"""User-facing selector for an installed record."""
|
|
230
|
+
return display_spec_for_record(record)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def resolve_tool_arg(
|
|
234
|
+
spec: str,
|
|
235
|
+
*,
|
|
236
|
+
settings: Settings | None = None,
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Resolve *spec* or ``@id:<id>`` to an installed tool id.
|
|
239
|
+
|
|
240
|
+
Raises ValueError when the spec cannot be resolved.
|
|
241
|
+
"""
|
|
242
|
+
return resolve_tool_name(spec, settings=settings)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _binary_spec_aliases(spec: str) -> set[str]:
|
|
246
|
+
"""Return equivalent user-facing forms for a binary install spec."""
|
|
247
|
+
aliases = {spec}
|
|
248
|
+
repo_spec = parse_spec(spec)
|
|
249
|
+
if repo_spec is None:
|
|
250
|
+
aliases.add(strip_version_suffix(spec))
|
|
251
|
+
return aliases
|
|
252
|
+
|
|
253
|
+
hosted = f"{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}"
|
|
254
|
+
aliases.add(hosted)
|
|
255
|
+
if repo_spec.host == "github.com" and repo_spec.platform == "github":
|
|
256
|
+
aliases.add(f"{repo_spec.owner}/{repo_spec.repo}")
|
|
257
|
+
return aliases
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _record_lookup_aliases(record: ToolRecord) -> set[str]:
|
|
261
|
+
"""Names a user may type for an installed record, excluding exact ids."""
|
|
262
|
+
aliases = {record.pkg(), record.spec, display_spec_for_record(record)}
|
|
263
|
+
aliases.update(id_prefixes(record.name))
|
|
264
|
+
if record.backend == BackendType.BINARY.value:
|
|
265
|
+
aliases.update(_binary_spec_aliases(record.spec))
|
|
266
|
+
return {alias for alias in aliases if alias and alias != record.name}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _input_lookup_aliases(spec: str) -> tuple[BackendType | None, set[str]]:
|
|
270
|
+
"""Normalize a user-supplied tool argument for installed-record lookup."""
|
|
271
|
+
forced_backend, clean_spec = strip_protocol(spec)
|
|
272
|
+
aliases = {spec, clean_spec}
|
|
273
|
+
aliases.update(_binary_spec_aliases(clean_spec))
|
|
274
|
+
return forced_backend, {alias for alias in aliases if alias}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _resolve_from_installed_metadata(spec: str, settings: Settings) -> str | None:
|
|
278
|
+
"""Resolve *spec* against installed ``ixt.json`` records, if unambiguous."""
|
|
279
|
+
records = [ToolRecord.load_json(meta) for meta in settings.iter_installed_metadata()]
|
|
280
|
+
if is_id_selector(spec):
|
|
281
|
+
tool_id = parse_id_selector(spec)
|
|
282
|
+
if any(record.name == tool_id for record in records):
|
|
283
|
+
return tool_id
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
forced_backend, input_aliases = _input_lookup_aliases(spec)
|
|
287
|
+
lookup_records = [
|
|
288
|
+
record
|
|
289
|
+
for record in records
|
|
290
|
+
if forced_backend is None or record.backend == forced_backend.value
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
for record in records:
|
|
294
|
+
if record.name == spec:
|
|
295
|
+
return record.name
|
|
296
|
+
|
|
297
|
+
candidates: dict[str, ToolRecord] = {}
|
|
298
|
+
ambiguous: set[str] = set()
|
|
299
|
+
for record in lookup_records:
|
|
300
|
+
for alias in _record_lookup_aliases(record):
|
|
301
|
+
if alias in ambiguous:
|
|
302
|
+
continue
|
|
303
|
+
existing = candidates.get(alias)
|
|
304
|
+
if existing is not None and existing.name != record.name:
|
|
305
|
+
del candidates[alias]
|
|
306
|
+
ambiguous.add(alias)
|
|
307
|
+
continue
|
|
308
|
+
candidates[alias] = record
|
|
309
|
+
|
|
310
|
+
for alias in input_aliases:
|
|
311
|
+
if alias in ambiguous:
|
|
312
|
+
matches = sorted(
|
|
313
|
+
record.name for record in lookup_records if alias in _record_lookup_aliases(record)
|
|
314
|
+
)
|
|
315
|
+
joined = ", ".join(matches)
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"Tool selector '{spec}' is ambiguous: {joined}. "
|
|
318
|
+
f"Use {', '.join('@id:' + name for name in matches)}."
|
|
319
|
+
)
|
|
320
|
+
if alias in candidates:
|
|
321
|
+
return candidates[alias].name
|
|
322
|
+
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def resolve_tool_name(spec: str, *, settings: Settings | None = None) -> str:
|
|
327
|
+
"""Resolve a package spec or namespaced tool_name to the installed tool_name.
|
|
328
|
+
|
|
329
|
+
Lookup order:
|
|
330
|
+
1. If *spec* matches an existing env dir directly, return it as-is.
|
|
331
|
+
2. If *spec* matches exactly one installed metadata alias, return that record.
|
|
332
|
+
3. Fall back to ``plan_install`` spec resolution
|
|
333
|
+
(e.g. ``mikefarah/yq`` → ``yq.mikefarah.github``).
|
|
334
|
+
"""
|
|
335
|
+
settings = settings or get_settings()
|
|
336
|
+
if is_id_selector(spec):
|
|
337
|
+
tool_id = parse_id_selector(spec)
|
|
338
|
+
env_dir = settings.get_tool_env_dir(tool_id)
|
|
339
|
+
if env_dir.exists():
|
|
340
|
+
return tool_id
|
|
341
|
+
raise ValueError(f"Tool id '{tool_id}' is not installed")
|
|
342
|
+
|
|
343
|
+
env_dir = settings.get_tool_env_dir(spec)
|
|
344
|
+
if env_dir.exists():
|
|
345
|
+
return spec
|
|
346
|
+
installed_name = _resolve_from_installed_metadata(spec, settings)
|
|
347
|
+
if installed_name is not None:
|
|
348
|
+
return installed_name
|
|
349
|
+
return plan_install(spec, settings=settings).tool_name
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def install_tool(
|
|
353
|
+
package_spec: str,
|
|
354
|
+
*,
|
|
355
|
+
inject: list[str] | None = None,
|
|
356
|
+
expose_rules: list[str] | None = None,
|
|
357
|
+
slot: str | None = None,
|
|
358
|
+
force: bool = False,
|
|
359
|
+
backend_type: BackendType | None = None,
|
|
360
|
+
runtime: str | None = None,
|
|
361
|
+
setup_path: Path | None = None,
|
|
362
|
+
node_shim: bool | None = None,
|
|
363
|
+
asset_pattern: str | None = None,
|
|
364
|
+
settings: Settings | None = None,
|
|
365
|
+
bare: bool = False,
|
|
366
|
+
) -> ToolRecord:
|
|
367
|
+
"""Install one isolated tool, expose its binaries, and return its record.
|
|
368
|
+
|
|
369
|
+
``bare=True`` creates the env without exposing any shim; the caller can
|
|
370
|
+
run ``ixt tool config <target> expose ...`` afterwards to wire them up.
|
|
371
|
+
"""
|
|
372
|
+
settings = settings or get_settings()
|
|
373
|
+
validate_slot(slot)
|
|
374
|
+
plan = plan_install(
|
|
375
|
+
package_spec,
|
|
376
|
+
inject=inject,
|
|
377
|
+
expose_rules=expose_rules,
|
|
378
|
+
slot=slot,
|
|
379
|
+
backend_type=backend_type,
|
|
380
|
+
setup_path=setup_path,
|
|
381
|
+
settings=settings,
|
|
382
|
+
bare=bare,
|
|
383
|
+
)
|
|
384
|
+
bt = plan.backend
|
|
385
|
+
clean_spec = plan.resolved_spec
|
|
386
|
+
pkg_name = plan.pkg_name
|
|
387
|
+
tool_name = plan.tool_name
|
|
388
|
+
env_dir = plan.env_dir
|
|
389
|
+
setup_path = plan.setup_path
|
|
390
|
+
|
|
391
|
+
backend = get_backend(bt, settings=settings)
|
|
392
|
+
|
|
393
|
+
if env_dir.exists():
|
|
394
|
+
if not force:
|
|
395
|
+
raise ToolAlreadyInstalledError(tool_name, env_dir)
|
|
396
|
+
# Remove old exposed bins before wiping the env.
|
|
397
|
+
meta_file = settings.get_tool_metadata_file(tool_name)
|
|
398
|
+
if meta_file.exists():
|
|
399
|
+
old = ToolRecord.load_json(meta_file)
|
|
400
|
+
unexpose_tool(old.exposed_bins, settings.bin_dir)
|
|
401
|
+
_remove_env_dir(env_dir, settings)
|
|
402
|
+
|
|
403
|
+
rules = plan.expose_rules
|
|
404
|
+
created = False
|
|
405
|
+
try:
|
|
406
|
+
created = backend.create_env(env_dir)
|
|
407
|
+
all_specs = [clean_spec] if bt == BackendType.BINARY else [clean_spec, *plan.inject]
|
|
408
|
+
install_kwargs: dict = {}
|
|
409
|
+
if bt == BackendType.NODE:
|
|
410
|
+
if runtime:
|
|
411
|
+
install_kwargs["runtime"] = runtime
|
|
412
|
+
if node_shim is not None:
|
|
413
|
+
install_kwargs["node_shim"] = node_shim
|
|
414
|
+
if setup_path and bt == BackendType.BINARY:
|
|
415
|
+
install_kwargs["setup_path"] = setup_path
|
|
416
|
+
if asset_pattern and bt == BackendType.BINARY:
|
|
417
|
+
install_kwargs["asset_pattern"] = asset_pattern
|
|
418
|
+
extra = backend.install_packages(env_dir, all_specs, **install_kwargs)
|
|
419
|
+
|
|
420
|
+
version = backend.installed_version(env_dir, pkg_name)
|
|
421
|
+
|
|
422
|
+
if rules:
|
|
423
|
+
result = expose_tool(
|
|
424
|
+
pkg_name,
|
|
425
|
+
backend,
|
|
426
|
+
env_dir,
|
|
427
|
+
settings.bin_dir,
|
|
428
|
+
rules,
|
|
429
|
+
overwrite=force,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if result.skipped:
|
|
433
|
+
from ixt.core.expose import warn_if_skipped
|
|
434
|
+
from ixt.libs.logger import get_logger
|
|
435
|
+
|
|
436
|
+
warn_if_skipped(result, tool_name, get_logger("install"))
|
|
437
|
+
linked_bins = result.linked
|
|
438
|
+
else:
|
|
439
|
+
linked_bins = {}
|
|
440
|
+
|
|
441
|
+
record = ToolRecord(
|
|
442
|
+
name=tool_name,
|
|
443
|
+
pkg_name=pkg_name,
|
|
444
|
+
backend=bt.value,
|
|
445
|
+
spec=clean_spec,
|
|
446
|
+
env_dir=str(env_dir),
|
|
447
|
+
version=version,
|
|
448
|
+
injected=list(plan.inject),
|
|
449
|
+
expose_rules=rules,
|
|
450
|
+
exposed_bins=linked_bins,
|
|
451
|
+
node_shim=node_shim,
|
|
452
|
+
asset_pattern=extra.get("asset_pattern") if extra else None,
|
|
453
|
+
asset_pattern_forced=bool(extra.get("asset_pattern_forced")) if extra else False,
|
|
454
|
+
)
|
|
455
|
+
record.save_json(settings.get_tool_metadata_file(tool_name))
|
|
456
|
+
|
|
457
|
+
# Run post-install hook if defined (binary backend only for now).
|
|
458
|
+
if bt == BackendType.BINARY and linked_bins:
|
|
459
|
+
_run_hook(env_dir, linked_bins, "post_install")
|
|
460
|
+
|
|
461
|
+
return record
|
|
462
|
+
except Exception:
|
|
463
|
+
if created:
|
|
464
|
+
_remove_env_dir(env_dir, settings, ignore_errors=True)
|
|
465
|
+
raise
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def uninstall_tool(
|
|
469
|
+
tool_name: str,
|
|
470
|
+
*,
|
|
471
|
+
settings: Settings | None = None,
|
|
472
|
+
) -> Path:
|
|
473
|
+
"""Remove exposed binaries and the tool environment from disk."""
|
|
474
|
+
settings = settings or get_settings()
|
|
475
|
+
env_dir = settings.get_tool_env_dir(tool_name)
|
|
476
|
+
if not env_dir.exists():
|
|
477
|
+
raise FileNotFoundError(env_dir)
|
|
478
|
+
|
|
479
|
+
# Clean up exposed bins first.
|
|
480
|
+
meta_file = settings.get_tool_metadata_file(tool_name)
|
|
481
|
+
if meta_file.exists():
|
|
482
|
+
record = ToolRecord.load_json(meta_file)
|
|
483
|
+
|
|
484
|
+
# Run pre-uninstall hook before removing anything.
|
|
485
|
+
if record.exposed_bins:
|
|
486
|
+
_run_hook(env_dir, record.exposed_bins, "pre_uninstall")
|
|
487
|
+
|
|
488
|
+
unexpose_tool(record.exposed_bins, settings.bin_dir)
|
|
489
|
+
|
|
490
|
+
_remove_env_dir(env_dir, settings)
|
|
491
|
+
return env_dir
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def uninstall_all(
|
|
495
|
+
*,
|
|
496
|
+
settings: Settings | None = None,
|
|
497
|
+
) -> dict[str, Path | Exception]:
|
|
498
|
+
"""Uninstall every installed tool. Returns a map of tool_name -> removed path or error."""
|
|
499
|
+
settings = settings or get_settings()
|
|
500
|
+
results: dict[str, Path | Exception] = {}
|
|
501
|
+
|
|
502
|
+
for meta_file in settings.iter_installed_metadata():
|
|
503
|
+
name = meta_file.parent.name
|
|
504
|
+
try:
|
|
505
|
+
results[name] = uninstall_tool(name, settings=settings)
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
results[name] = exc
|
|
508
|
+
|
|
509
|
+
return results
|