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/hooks.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Lifecycle hook execution engine with security validation.
|
|
2
|
+
|
|
3
|
+
Hooks are defined in ``ixt.setup.toml`` and run at two lifecycle points:
|
|
4
|
+
|
|
5
|
+
* ``post_install`` — after binaries are exposed
|
|
6
|
+
* ``pre_uninstall`` — before environment removal
|
|
7
|
+
|
|
8
|
+
Three security locks are enforced:
|
|
9
|
+
|
|
10
|
+
1. **bin-only**: command must start with ``{bin}``
|
|
11
|
+
2. **action whitelist**: last positional arg must be in the allowed set
|
|
12
|
+
3. **precheck** (post_install only): skip ``run`` if ``check`` succeeds with output
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ixt.config.setup_toml import HookConfig
|
|
20
|
+
from ixt.libs.logger import get_logger
|
|
21
|
+
from ixt.libs.shell import ShellError, shell_run_output
|
|
22
|
+
|
|
23
|
+
log = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Whitelists
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
POST_INSTALL_ACTIONS = frozenset({"init", "setup", "install"})
|
|
30
|
+
PRE_UNINSTALL_ACTIONS = frozenset({"cleanup", "uninstall", "teardown"})
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Template expansion
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _expand_template(cmd: str, bin_path: str, env_dir: str) -> str:
|
|
38
|
+
"""Replace ``{bin}`` and ``{env_dir}`` placeholders."""
|
|
39
|
+
return cmd.replace("{bin}", bin_path).replace("{env_dir}", env_dir)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Validation
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HookValidationError(ValueError):
|
|
48
|
+
"""Raised when a hook command fails security validation."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _split_args(cmd: str) -> list[str]:
|
|
52
|
+
"""Split a command string into arguments (simple whitespace split).
|
|
53
|
+
|
|
54
|
+
Respects quoted strings for paths with spaces.
|
|
55
|
+
"""
|
|
56
|
+
import shlex
|
|
57
|
+
|
|
58
|
+
return shlex.split(cmd)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _last_positional_arg(args: list[str]) -> str | None:
|
|
62
|
+
"""Return the last non-flag argument (not starting with ``-``)."""
|
|
63
|
+
for arg in reversed(args):
|
|
64
|
+
if not arg.startswith("-"):
|
|
65
|
+
return arg
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_hook(
|
|
70
|
+
cmd: str,
|
|
71
|
+
*,
|
|
72
|
+
bin_path: str,
|
|
73
|
+
allowed_actions: frozenset[str],
|
|
74
|
+
) -> list[str]:
|
|
75
|
+
"""Validate a hook command against the three security locks.
|
|
76
|
+
|
|
77
|
+
Returns the parsed argument list ready for execution.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
HookValidationError: if any lock fails.
|
|
81
|
+
"""
|
|
82
|
+
args = _split_args(cmd)
|
|
83
|
+
|
|
84
|
+
if not args:
|
|
85
|
+
raise HookValidationError("Hook command is empty")
|
|
86
|
+
|
|
87
|
+
# Lock 1: must start with {bin} (already expanded)
|
|
88
|
+
if args[0] != bin_path:
|
|
89
|
+
raise HookValidationError(
|
|
90
|
+
f"Hook command must start with {{bin}} ({bin_path}), got: {args[0]}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Lock 2: last positional arg must be in the whitelist
|
|
94
|
+
if len(args) < 2:
|
|
95
|
+
raise HookValidationError("Hook command must include an action argument")
|
|
96
|
+
|
|
97
|
+
action = _last_positional_arg(args[1:])
|
|
98
|
+
if action is None:
|
|
99
|
+
raise HookValidationError("Hook command has no positional action argument")
|
|
100
|
+
|
|
101
|
+
if action not in allowed_actions:
|
|
102
|
+
raise HookValidationError(
|
|
103
|
+
f"Action '{action}' not in allowed set: {sorted(allowed_actions)}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return args
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Execution
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_hook(
|
|
115
|
+
hook: HookConfig,
|
|
116
|
+
*,
|
|
117
|
+
bin_path: str,
|
|
118
|
+
env_dir: Path,
|
|
119
|
+
allowed_actions: frozenset[str],
|
|
120
|
+
) -> bool:
|
|
121
|
+
"""Execute a lifecycle hook if defined.
|
|
122
|
+
|
|
123
|
+
Returns True if the hook ran, False if skipped.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
HookValidationError: if the command fails validation.
|
|
127
|
+
"""
|
|
128
|
+
if not hook.run:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
env_dir_str = str(env_dir)
|
|
132
|
+
|
|
133
|
+
# Precheck: if check is defined and succeeds with output, skip run.
|
|
134
|
+
if hook.check:
|
|
135
|
+
check_cmd = _expand_template(hook.check, bin_path, env_dir_str)
|
|
136
|
+
check_args = validate_hook(check_cmd, bin_path=bin_path, allowed_actions=allowed_actions)
|
|
137
|
+
try:
|
|
138
|
+
output = shell_run_output(check_args)
|
|
139
|
+
if output.strip():
|
|
140
|
+
log.debug("Hook precheck passed (non-empty output), skipping run")
|
|
141
|
+
return False
|
|
142
|
+
except ShellError:
|
|
143
|
+
# Check failed (non-zero exit) → proceed with run
|
|
144
|
+
log.debug("Hook precheck failed (non-zero exit), proceeding with run")
|
|
145
|
+
|
|
146
|
+
# Execute run
|
|
147
|
+
run_cmd = _expand_template(hook.run, bin_path, env_dir_str)
|
|
148
|
+
run_args = validate_hook(run_cmd, bin_path=bin_path, allowed_actions=allowed_actions)
|
|
149
|
+
|
|
150
|
+
log.info(f"Running hook: {' '.join(run_args)}")
|
|
151
|
+
shell_run_output(run_args)
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_post_install(
|
|
156
|
+
hook: HookConfig,
|
|
157
|
+
*,
|
|
158
|
+
bin_path: str,
|
|
159
|
+
env_dir: Path,
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""Run a post-install hook with post_install action whitelist."""
|
|
162
|
+
return run_hook(
|
|
163
|
+
hook,
|
|
164
|
+
bin_path=bin_path,
|
|
165
|
+
env_dir=env_dir,
|
|
166
|
+
allowed_actions=POST_INSTALL_ACTIONS,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_pre_uninstall(
|
|
171
|
+
hook: HookConfig,
|
|
172
|
+
*,
|
|
173
|
+
bin_path: str,
|
|
174
|
+
env_dir: Path,
|
|
175
|
+
) -> bool:
|
|
176
|
+
"""Run a pre-uninstall hook with pre_uninstall action whitelist."""
|
|
177
|
+
return run_hook(
|
|
178
|
+
hook,
|
|
179
|
+
bin_path=bin_path,
|
|
180
|
+
env_dir=env_dir,
|
|
181
|
+
allowed_actions=PRE_UNINSTALL_ACTIONS,
|
|
182
|
+
)
|
ixt/core/identity.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tool identity helpers.
|
|
2
|
+
|
|
3
|
+
User-facing commands normally accept package specs (``ruff``, ``alajmo/mani``).
|
|
4
|
+
The installed environment id is the unique technical reference
|
|
5
|
+
(``mani.alajmo.github``) and can be selected explicitly with ``@id:<id>``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ixt.config.models import ToolRecord
|
|
14
|
+
from ixt.core.backend import BackendType, strip_protocol
|
|
15
|
+
from ixt.libs.req_spec import parse_requirement
|
|
16
|
+
from ixt.net.source import parse_spec, strip_version_suffix
|
|
17
|
+
|
|
18
|
+
ID_SELECTOR_PREFIX = "@id:"
|
|
19
|
+
_SLOT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class ToolIdentity:
|
|
24
|
+
"""The stable identity surfaces for one installed tool."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
spec: str
|
|
28
|
+
display: str
|
|
29
|
+
backend: str
|
|
30
|
+
pkg: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_id_selector(value: str) -> bool:
|
|
34
|
+
return value.startswith(ID_SELECTOR_PREFIX)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_id_selector(value: str) -> str:
|
|
38
|
+
"""Return the exact id from ``@id:<id>``, or raise ValueError."""
|
|
39
|
+
if not is_id_selector(value):
|
|
40
|
+
raise ValueError(f"Not an id selector: {value!r}")
|
|
41
|
+
tool_id = value[len(ID_SELECTOR_PREFIX) :].strip()
|
|
42
|
+
if not tool_id:
|
|
43
|
+
raise ValueError("Empty @id: selector")
|
|
44
|
+
return tool_id
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_slot(slot: str | None) -> str | None:
|
|
48
|
+
"""Validate a side-by-side install slot.
|
|
49
|
+
|
|
50
|
+
A slot is a single id segment that gets prepended to the generated base id.
|
|
51
|
+
It deliberately excludes dots, slashes, colons, and spaces so id-prefix
|
|
52
|
+
matching remains predictable.
|
|
53
|
+
"""
|
|
54
|
+
if slot is None:
|
|
55
|
+
return None
|
|
56
|
+
if not _SLOT_RE.match(slot):
|
|
57
|
+
raise ValueError("Invalid slot: use only letters, digits, '-' or '_'")
|
|
58
|
+
return slot
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def apply_slot(base_id: str, slot: str | None) -> str:
|
|
62
|
+
slot = validate_slot(slot)
|
|
63
|
+
return f"{slot}.{base_id}" if slot else base_id
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def make_tool_id(bt: BackendType, pkg_name: str, spec: str, *, slot: str | None = None) -> str:
|
|
67
|
+
"""Compute the canonical installed tool id from backend, package and spec."""
|
|
68
|
+
_, clean_spec = strip_protocol(spec)
|
|
69
|
+
if bt == BackendType.BINARY:
|
|
70
|
+
repo_spec = parse_spec(clean_spec)
|
|
71
|
+
base_id = (
|
|
72
|
+
f"{repo_spec.repo}.{repo_spec.owner}.{repo_spec.platform}" if repo_spec else pkg_name
|
|
73
|
+
)
|
|
74
|
+
elif bt == BackendType.NODE:
|
|
75
|
+
if clean_spec.startswith("@"):
|
|
76
|
+
scope = clean_spec.lstrip("@").split("/")[0]
|
|
77
|
+
short_pkg = pkg_name.split("/")[-1] if "/" in pkg_name else pkg_name.lstrip("@")
|
|
78
|
+
base_id = f"{short_pkg}.{scope}.npm"
|
|
79
|
+
else:
|
|
80
|
+
base_id = f"{pkg_name}.npm"
|
|
81
|
+
else:
|
|
82
|
+
base_id = f"{pkg_name}.pypi"
|
|
83
|
+
return apply_slot(base_id, slot)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def slot_from_id(tool_id: str, base_id: str) -> str | None:
|
|
87
|
+
"""Return the slot prefix if *tool_id* is a slotted variant of *base_id*."""
|
|
88
|
+
if tool_id == base_id:
|
|
89
|
+
return None
|
|
90
|
+
suffix = f".{base_id}"
|
|
91
|
+
if not tool_id.endswith(suffix):
|
|
92
|
+
return None
|
|
93
|
+
slot = tool_id[: -len(suffix)]
|
|
94
|
+
return validate_slot(slot)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def id_prefixes(tool_id: str) -> set[str]:
|
|
98
|
+
"""Return segment prefixes for a dotted id, excluding the full id."""
|
|
99
|
+
parts = [part for part in tool_id.split(".") if part]
|
|
100
|
+
return {".".join(parts[:i]) for i in range(1, len(parts))}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _binary_display_spec(spec: str) -> str:
|
|
104
|
+
_, clean = strip_protocol(spec)
|
|
105
|
+
repo_spec = parse_spec(clean)
|
|
106
|
+
if repo_spec is None:
|
|
107
|
+
return strip_version_suffix(clean)
|
|
108
|
+
if repo_spec.platform == "github" and repo_spec.host == "github.com":
|
|
109
|
+
return f"{repo_spec.owner}/{repo_spec.repo}"
|
|
110
|
+
return f"{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _node_display_spec(spec: str) -> str:
|
|
114
|
+
from ixt.backends.node import parse_npm_spec
|
|
115
|
+
|
|
116
|
+
name, _version = parse_npm_spec(spec)
|
|
117
|
+
return name
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _python_display_spec(spec: str, fallback: str) -> str:
|
|
121
|
+
try:
|
|
122
|
+
return parse_requirement(spec).name
|
|
123
|
+
except ValueError:
|
|
124
|
+
return fallback
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def display_spec_for_record(record: ToolRecord) -> str:
|
|
128
|
+
"""Return the normal user-facing selector for an installed record."""
|
|
129
|
+
if record.source == "local":
|
|
130
|
+
return record.spec
|
|
131
|
+
if record.backend == "binary":
|
|
132
|
+
return _binary_display_spec(record.spec)
|
|
133
|
+
if record.backend == "node":
|
|
134
|
+
return _node_display_spec(record.spec)
|
|
135
|
+
if record.backend == "python":
|
|
136
|
+
return _python_display_spec(record.spec, record.pkg())
|
|
137
|
+
return record.pkg()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def identity_for_record(record: ToolRecord) -> ToolIdentity:
|
|
141
|
+
spec = display_spec_for_record(record)
|
|
142
|
+
return ToolIdentity(
|
|
143
|
+
id=record.name,
|
|
144
|
+
spec=spec,
|
|
145
|
+
display=spec,
|
|
146
|
+
backend=record.backend,
|
|
147
|
+
pkg=record.pkg(),
|
|
148
|
+
)
|
ixt/core/inject.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Inject and uninject packages into/from an installed tool's environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ixt.config.models import ToolRecord
|
|
8
|
+
from ixt.config.settings import Settings, get_settings
|
|
9
|
+
from ixt.core.backend import BackendType, get_backend
|
|
10
|
+
from ixt.core.expose import expose_tool, unexpose_tool
|
|
11
|
+
from ixt.libs.req_spec import parse_requirement
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PackageAlreadyInjectedError(ValueError):
|
|
15
|
+
"""Raised when trying to inject a package that's already injected."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, package: str, tool_name: str):
|
|
18
|
+
self.package = package
|
|
19
|
+
self.tool_name = tool_name
|
|
20
|
+
super().__init__(f"Package '{package}' is already injected in '{tool_name}'")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PackageNotInjectedError(ValueError):
|
|
24
|
+
"""Raised when trying to uninject a package that isn't injected."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, package: str, tool_name: str):
|
|
27
|
+
self.package = package
|
|
28
|
+
self.tool_name = tool_name
|
|
29
|
+
super().__init__(f"Package '{package}' is not injected in '{tool_name}'")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def inject_packages(
|
|
33
|
+
tool_name: str,
|
|
34
|
+
packages: list[str],
|
|
35
|
+
*,
|
|
36
|
+
settings: Settings | None = None,
|
|
37
|
+
) -> ToolRecord:
|
|
38
|
+
"""Inject additional packages into an installed tool's environment.
|
|
39
|
+
|
|
40
|
+
Validates that none of the packages are already injected, installs them
|
|
41
|
+
into the existing environment, re-exposes binaries, and updates the record.
|
|
42
|
+
|
|
43
|
+
Returns the updated ToolRecord.
|
|
44
|
+
"""
|
|
45
|
+
settings = settings or get_settings()
|
|
46
|
+
meta_file = settings.get_tool_metadata_file(tool_name)
|
|
47
|
+
|
|
48
|
+
if not meta_file.exists():
|
|
49
|
+
raise FileNotFoundError(f"Tool '{tool_name}' is not installed")
|
|
50
|
+
|
|
51
|
+
record = ToolRecord.load_json(meta_file)
|
|
52
|
+
bt = BackendType(record.backend)
|
|
53
|
+
if bt == BackendType.BINARY:
|
|
54
|
+
raise ValueError("Binary backend does not support injected packages")
|
|
55
|
+
|
|
56
|
+
# Check for duplicates against existing injected packages.
|
|
57
|
+
existing_names = {parse_requirement(s).name for s in record.injected}
|
|
58
|
+
for spec in packages:
|
|
59
|
+
name = parse_requirement(spec).name
|
|
60
|
+
if name in existing_names:
|
|
61
|
+
raise PackageAlreadyInjectedError(name, tool_name)
|
|
62
|
+
|
|
63
|
+
env_dir = Path(record.env_dir)
|
|
64
|
+
backend = get_backend(bt, settings=settings)
|
|
65
|
+
|
|
66
|
+
backend.install_packages(env_dir, packages)
|
|
67
|
+
|
|
68
|
+
# Update injected list (sorted for deterministic output).
|
|
69
|
+
record.injected = sorted([*record.injected, *packages])
|
|
70
|
+
|
|
71
|
+
# Re-expose to pick up any new binaries from injected packages.
|
|
72
|
+
unexpose_tool(record.exposed_bins, settings.bin_dir)
|
|
73
|
+
result = expose_tool(
|
|
74
|
+
record.pkg(),
|
|
75
|
+
backend,
|
|
76
|
+
env_dir,
|
|
77
|
+
settings.bin_dir,
|
|
78
|
+
record.expose_rules,
|
|
79
|
+
overwrite=True,
|
|
80
|
+
)
|
|
81
|
+
record.exposed_bins = result.linked
|
|
82
|
+
|
|
83
|
+
record.save_json(meta_file)
|
|
84
|
+
return record
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def uninject_packages(
|
|
88
|
+
tool_name: str,
|
|
89
|
+
packages: list[str],
|
|
90
|
+
*,
|
|
91
|
+
settings: Settings | None = None,
|
|
92
|
+
) -> ToolRecord:
|
|
93
|
+
"""Remove injected packages from an installed tool's environment.
|
|
94
|
+
|
|
95
|
+
Validates that the packages are currently injected, uninstalls them,
|
|
96
|
+
re-exposes binaries (some may disappear), and updates the record.
|
|
97
|
+
|
|
98
|
+
Returns the updated ToolRecord.
|
|
99
|
+
"""
|
|
100
|
+
settings = settings or get_settings()
|
|
101
|
+
meta_file = settings.get_tool_metadata_file(tool_name)
|
|
102
|
+
|
|
103
|
+
if not meta_file.exists():
|
|
104
|
+
raise FileNotFoundError(f"Tool '{tool_name}' is not installed")
|
|
105
|
+
|
|
106
|
+
record = ToolRecord.load_json(meta_file)
|
|
107
|
+
|
|
108
|
+
# Build a map of injected name → spec for validation.
|
|
109
|
+
injected_names: dict[str, str] = {
|
|
110
|
+
parse_requirement(spec).name: spec for spec in record.injected
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
removing_names: set[str] = set()
|
|
114
|
+
for spec in packages:
|
|
115
|
+
name = parse_requirement(spec).name
|
|
116
|
+
if name not in injected_names:
|
|
117
|
+
raise PackageNotInjectedError(name, tool_name)
|
|
118
|
+
removing_names.add(name)
|
|
119
|
+
|
|
120
|
+
env_dir = Path(record.env_dir)
|
|
121
|
+
bt = BackendType(record.backend)
|
|
122
|
+
backend = get_backend(bt, settings=settings)
|
|
123
|
+
|
|
124
|
+
for name in removing_names:
|
|
125
|
+
backend.uninstall_package(env_dir, name)
|
|
126
|
+
|
|
127
|
+
# Remove from injected list (reuse already-parsed names).
|
|
128
|
+
record.injected = [spec for name, spec in injected_names.items() if name not in removing_names]
|
|
129
|
+
|
|
130
|
+
# Re-expose (removed packages may have contributed binaries).
|
|
131
|
+
unexpose_tool(record.exposed_bins, settings.bin_dir)
|
|
132
|
+
result = expose_tool(
|
|
133
|
+
record.pkg(),
|
|
134
|
+
backend,
|
|
135
|
+
env_dir,
|
|
136
|
+
settings.bin_dir,
|
|
137
|
+
record.expose_rules,
|
|
138
|
+
overwrite=True,
|
|
139
|
+
)
|
|
140
|
+
record.exposed_bins = result.linked
|
|
141
|
+
|
|
142
|
+
record.save_json(meta_file)
|
|
143
|
+
return record
|