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/cli/cmd_install.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""``ixt tool add/install/uninstall`` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ixt.cli.render import format_exposed_bins, id_suffix
|
|
9
|
+
from ixt.config.models import ToolRecord
|
|
10
|
+
from ixt.config.settings import get_settings
|
|
11
|
+
from ixt.core.backend import BackendType
|
|
12
|
+
from ixt.core.install import display_tool_name, resolve_tool_arg
|
|
13
|
+
from ixt.libs.constants import EXPOSE_MAIN
|
|
14
|
+
from ixt.libs.logger import get_logger
|
|
15
|
+
from ixt.libs.style import bold, cyan, dim, green, red, yellow
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _log_not_found_hint(logger, pkg: str) -> None:
|
|
19
|
+
"""Context-aware hint when install fails with 404 / package not found.
|
|
20
|
+
|
|
21
|
+
A simple name (no ``/``, no ``@``) is auto-detected as PyPI. When that
|
|
22
|
+
lookup 404s, the user most likely meant a different backend — show the
|
|
23
|
+
three concrete alternatives instead of a generic "check the name" note.
|
|
24
|
+
"""
|
|
25
|
+
is_simple = "/" not in pkg and "@" not in pkg
|
|
26
|
+
if is_simple:
|
|
27
|
+
header = f"Hint: '{pkg}' not found on PyPI. Try:"
|
|
28
|
+
logger.error(f" {dim(header)}")
|
|
29
|
+
logger.error(f" {dim('ixt tool install @npm:' + pkg)} {dim('(npm package)')}")
|
|
30
|
+
logger.error(f" {dim('ixt tool install owner/' + pkg)} {dim('(GitHub repo)')}")
|
|
31
|
+
logger.error(f" {dim('ixt tool install @gl:owner/' + pkg)} {dim('(GitLab repo)')}")
|
|
32
|
+
else:
|
|
33
|
+
logger.error(
|
|
34
|
+
f" {dim('Hint: check the package name.')}"
|
|
35
|
+
f" {dim('Use owner/repo for GitHub, @scope/pkg for npm')}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
40
|
+
logger = get_logger("install")
|
|
41
|
+
dry_run = getattr(args, "dry_run", False)
|
|
42
|
+
from_path = getattr(args, "from_path", None)
|
|
43
|
+
|
|
44
|
+
if args.package and from_path:
|
|
45
|
+
logger.error("--from is mutually exclusive with the positional package spec.")
|
|
46
|
+
return 1
|
|
47
|
+
if not args.package and not from_path:
|
|
48
|
+
logger.error("either a package spec or --from PATH is required.")
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
if not from_path:
|
|
52
|
+
gate = _check_setup_file_gate(args, logger)
|
|
53
|
+
if gate is not None:
|
|
54
|
+
return gate
|
|
55
|
+
|
|
56
|
+
if dry_run:
|
|
57
|
+
if from_path:
|
|
58
|
+
return _cmd_install_from_local_dry_run(args, from_path, logger)
|
|
59
|
+
return _cmd_install_dry_run(args, logger)
|
|
60
|
+
|
|
61
|
+
if from_path:
|
|
62
|
+
return _cmd_install_from_local(args, from_path, logger)
|
|
63
|
+
|
|
64
|
+
from ixt.core.backend import BackendType, detect_backend
|
|
65
|
+
from ixt.core.install import ToolAlreadyInstalledError, install_tool
|
|
66
|
+
|
|
67
|
+
setup_path = Path(args.setup) if getattr(args, "setup", None) else None
|
|
68
|
+
asset_pattern = getattr(args, "asset_pattern", None)
|
|
69
|
+
if asset_pattern:
|
|
70
|
+
try:
|
|
71
|
+
bt = detect_backend(args.package)
|
|
72
|
+
except Exception:
|
|
73
|
+
bt = None
|
|
74
|
+
if bt is not None and bt != BackendType.BINARY:
|
|
75
|
+
logger.warn(
|
|
76
|
+
f"--asset-pattern ignored: only applies to the binary backend (detected {bt.value})"
|
|
77
|
+
)
|
|
78
|
+
asset_pattern = None
|
|
79
|
+
|
|
80
|
+
runtime_arg, node_shim = _resolve_runtime(getattr(args, "runtime", "bun"))
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
record = install_tool(
|
|
84
|
+
args.package,
|
|
85
|
+
slot=args.slot,
|
|
86
|
+
force=args.reinstall,
|
|
87
|
+
backend_type=None,
|
|
88
|
+
runtime=runtime_arg,
|
|
89
|
+
setup_path=setup_path,
|
|
90
|
+
node_shim=node_shim,
|
|
91
|
+
asset_pattern=asset_pattern,
|
|
92
|
+
bare=args.bare,
|
|
93
|
+
)
|
|
94
|
+
except ToolAlreadyInstalledError as e:
|
|
95
|
+
logger.error(f"{e}")
|
|
96
|
+
logger.error(
|
|
97
|
+
f" {dim('Hint: use --reinstall to replace it, or ixt tool upgrade ' + args.package)}"
|
|
98
|
+
)
|
|
99
|
+
return 1
|
|
100
|
+
except ValueError as e:
|
|
101
|
+
logger.error(str(e))
|
|
102
|
+
return 1
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return _handle_install_error(e, args, logger)
|
|
105
|
+
|
|
106
|
+
settings = get_settings()
|
|
107
|
+
version = f" {dim(record.version)}" if record.version else ""
|
|
108
|
+
logger.info(
|
|
109
|
+
f" {green('+')} {bold(display_tool_name(record, settings))}{version} "
|
|
110
|
+
f"{id_suffix(record.name)}"
|
|
111
|
+
)
|
|
112
|
+
logger.info(f" exposed: {format_exposed_bins(record.exposed_bins)}")
|
|
113
|
+
|
|
114
|
+
if args.save:
|
|
115
|
+
_save_install_to_toml(args, record, logger)
|
|
116
|
+
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_add(args: argparse.Namespace) -> int:
|
|
121
|
+
"""Persist a tool in ixt.toml without installing it."""
|
|
122
|
+
from ixt.core.install import plan_install
|
|
123
|
+
from ixt.core.save import (
|
|
124
|
+
extract_version_from_spec,
|
|
125
|
+
save_tool,
|
|
126
|
+
tool_entry_key,
|
|
127
|
+
tool_install_spec,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
logger = get_logger("add")
|
|
131
|
+
try:
|
|
132
|
+
plan = plan_install(args.package, slot=args.slot, bare=args.bare)
|
|
133
|
+
except ValueError as e:
|
|
134
|
+
logger.error(str(e))
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
asset_pattern = getattr(args, "asset_pattern", None)
|
|
138
|
+
if asset_pattern and plan.backend != BackendType.BINARY:
|
|
139
|
+
logger.warn(
|
|
140
|
+
f"--asset-pattern ignored: only applies to the binary backend "
|
|
141
|
+
f"(detected {plan.backend.value})"
|
|
142
|
+
)
|
|
143
|
+
asset_pattern = None
|
|
144
|
+
|
|
145
|
+
runtime_arg, node_shim = _resolve_runtime(getattr(args, "runtime", None))
|
|
146
|
+
runtime_out = None
|
|
147
|
+
if plan.backend == BackendType.NODE:
|
|
148
|
+
runtime_out = "node" if runtime_arg == "node" else None
|
|
149
|
+
else:
|
|
150
|
+
node_shim = None
|
|
151
|
+
|
|
152
|
+
entry_key = tool_entry_key(plan.tool_name, plan.resolved_spec, plan.backend)
|
|
153
|
+
entry_install = None
|
|
154
|
+
if args.slot is not None:
|
|
155
|
+
entry_key = args.slot
|
|
156
|
+
entry_install = tool_install_spec(plan.resolved_spec, plan.backend)
|
|
157
|
+
|
|
158
|
+
path = save_tool(
|
|
159
|
+
entry_key,
|
|
160
|
+
install=entry_install,
|
|
161
|
+
version=extract_version_from_spec(plan.resolved_spec, plan.backend),
|
|
162
|
+
expose=[] if args.bare else None,
|
|
163
|
+
node_shim=node_shim,
|
|
164
|
+
runtime=runtime_out,
|
|
165
|
+
asset_pattern=asset_pattern,
|
|
166
|
+
)
|
|
167
|
+
logger.info(f" {green('+')} {bold(entry_key)} {dim('saved to ' + str(path))}")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cmd_install_from_local(args: argparse.Namespace, from_path: str, logger) -> int:
|
|
172
|
+
"""Handle ``ixt tool install --from <path>`` (bypass spec/registry resolution)."""
|
|
173
|
+
from ixt.core.install import ToolAlreadyInstalledError
|
|
174
|
+
from ixt.core.install_local import install_from_local
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
record = install_from_local(
|
|
178
|
+
Path(from_path),
|
|
179
|
+
slot=args.slot,
|
|
180
|
+
force=args.reinstall,
|
|
181
|
+
)
|
|
182
|
+
except ToolAlreadyInstalledError as e:
|
|
183
|
+
logger.error(f"{e}")
|
|
184
|
+
logger.error(f" {dim('Hint: use --reinstall to replace it.')}")
|
|
185
|
+
return 1
|
|
186
|
+
except ValueError as e:
|
|
187
|
+
logger.error(str(e))
|
|
188
|
+
return 1
|
|
189
|
+
|
|
190
|
+
version = f" {dim(record.version)}" if record.version else ""
|
|
191
|
+
logger.info(
|
|
192
|
+
f" {green('+')} {bold(display_tool_name(record))}{version} "
|
|
193
|
+
f"{id_suffix(record.name)} {dim('[local]')}"
|
|
194
|
+
)
|
|
195
|
+
logger.info(f" exposed: {format_exposed_bins(record.exposed_bins)}")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _cmd_install_from_local_dry_run(args: argparse.Namespace, from_path: str, logger) -> int:
|
|
200
|
+
"""Resolve a local install plan, print it, and exit without mutating."""
|
|
201
|
+
from ixt.core.install_local import plan_install_from_local
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
plan = plan_install_from_local(Path(from_path), slot=args.slot)
|
|
205
|
+
except ValueError as e:
|
|
206
|
+
logger.error(str(e))
|
|
207
|
+
return 1
|
|
208
|
+
|
|
209
|
+
logger.info(f"{dim('[dry-run]')} would install from local path:")
|
|
210
|
+
logger.info(
|
|
211
|
+
f" {bold(str(plan.source_path))} {dim('[' + plan.backend.value + ']')} "
|
|
212
|
+
f"{id_suffix(plan.tool_name)}"
|
|
213
|
+
)
|
|
214
|
+
logger.info(f" source: {plan.source_path}")
|
|
215
|
+
logger.info(f" package: {plan.pkg_name}")
|
|
216
|
+
logger.info(f" env: {dim(str(plan.env_dir))}")
|
|
217
|
+
expose_display = ", ".join(plan.expose_rules) if plan.expose_rules else dim("none")
|
|
218
|
+
logger.info(f" would expose: {expose_display}")
|
|
219
|
+
if plan.already_installed:
|
|
220
|
+
if args.reinstall:
|
|
221
|
+
logger.info(f" {yellow('note:')} env exists, would be reinstalled")
|
|
222
|
+
else:
|
|
223
|
+
logger.error(f" {red('note:')} env exists, would fail without --reinstall")
|
|
224
|
+
return 1
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_RUNTIME_MAP: dict[str, tuple[str | None, bool | None]] = {
|
|
229
|
+
"bun": ("bun", True), # default — rewrite #!node shebangs to bun
|
|
230
|
+
"bun-strict": ("bun", False), # bun runtime, shebangs untouched (host needs node)
|
|
231
|
+
"node": ("node", None), # node runtime — shim flag irrelevant
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _resolve_runtime(choice: str | None) -> tuple[str | None, bool | None]:
|
|
236
|
+
"""Return ``(runtime, node_shim)`` for the --runtime CLI choice."""
|
|
237
|
+
return _RUNTIME_MAP.get(choice or "", (choice, None))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _check_setup_file_gate(args: argparse.Namespace, logger) -> int | None:
|
|
241
|
+
"""Guard: --setup-file is gated behind IXT_ENABLE_SETUP_TOML (A/B test v0.1.0)."""
|
|
242
|
+
if not getattr(args, "setup", None):
|
|
243
|
+
return None
|
|
244
|
+
from ixt.config.flags import SETUP_TOML_ENV, is_setup_toml_enabled
|
|
245
|
+
|
|
246
|
+
if is_setup_toml_enabled():
|
|
247
|
+
return None
|
|
248
|
+
logger.error("ixt.setup.toml support is disabled in v0.1.0 (A/B test).")
|
|
249
|
+
logger.error(f" {dim(f'Set {SETUP_TOML_ENV}=1 to enable --setup-file and remote fetch.')}")
|
|
250
|
+
return 1
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _cmd_install_dry_run(args: argparse.Namespace, logger) -> int:
|
|
254
|
+
"""Resolve the install plan, print it, and exit without mutating."""
|
|
255
|
+
from ixt.core.install import plan_install
|
|
256
|
+
|
|
257
|
+
setup_path = Path(args.setup) if getattr(args, "setup", None) else None
|
|
258
|
+
try:
|
|
259
|
+
plan = plan_install(
|
|
260
|
+
args.package,
|
|
261
|
+
slot=args.slot,
|
|
262
|
+
setup_path=setup_path,
|
|
263
|
+
bare=args.bare,
|
|
264
|
+
)
|
|
265
|
+
except ValueError as e:
|
|
266
|
+
logger.error(str(e))
|
|
267
|
+
return 1
|
|
268
|
+
|
|
269
|
+
logger.info(f"{dim('[dry-run]')} would install:")
|
|
270
|
+
logger.info(
|
|
271
|
+
f" {bold(plan.resolved_spec)} {dim('[' + plan.backend.value + ']')} "
|
|
272
|
+
f"{id_suffix(plan.tool_name)}"
|
|
273
|
+
)
|
|
274
|
+
logger.info(f" spec: {plan.resolved_spec}")
|
|
275
|
+
logger.info(f" env: {dim(str(plan.env_dir))}")
|
|
276
|
+
expose_display = ", ".join(plan.expose_rules) if plan.expose_rules else dim("none")
|
|
277
|
+
logger.info(f" would expose: {expose_display}")
|
|
278
|
+
if plan.inject:
|
|
279
|
+
logger.info(f" inject: {', '.join(cyan(p) for p in plan.inject)}")
|
|
280
|
+
if plan.already_installed:
|
|
281
|
+
if args.reinstall:
|
|
282
|
+
logger.info(f" {yellow('note:')} env exists, would be reinstalled")
|
|
283
|
+
else:
|
|
284
|
+
logger.error(f" {red('note:')} env exists, would fail without --reinstall")
|
|
285
|
+
return 1
|
|
286
|
+
if plan.backend != BackendType.PYTHON:
|
|
287
|
+
logger.info(f" {dim('note: version resolved at install time (network)')}")
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _handle_install_error(exc: Exception, args: argparse.Namespace, logger) -> int:
|
|
292
|
+
"""Map an install_tool exception to a user-facing error with actionable hint."""
|
|
293
|
+
import os
|
|
294
|
+
|
|
295
|
+
from ixt.net.http import NotFoundError, RateLimitError
|
|
296
|
+
|
|
297
|
+
err_lower = str(exc).lower()
|
|
298
|
+
body = getattr(exc, "body", "") or ""
|
|
299
|
+
is_rate_limit = (
|
|
300
|
+
isinstance(exc, RateLimitError) or "rate limit" in err_lower or "rate limit" in body.lower()
|
|
301
|
+
)
|
|
302
|
+
is_not_found = isinstance(exc, NotFoundError) or "404" in str(exc) or "not found" in err_lower
|
|
303
|
+
is_no_asset = "No compatible asset" in str(exc) or "No release matching" in str(exc)
|
|
304
|
+
is_unauthorized = "401" in str(exc) or "unauthorized" in err_lower
|
|
305
|
+
|
|
306
|
+
if is_rate_limit:
|
|
307
|
+
logger.error(f"Failed to install '{args.package}': GitHub/GitLab API rate limit reached")
|
|
308
|
+
logger.error(f" {dim('Hint: export GITHUB_TOKEN=$(gh auth token) (or GITLAB_TOKEN)')}")
|
|
309
|
+
elif is_unauthorized:
|
|
310
|
+
# 401 on a public release endpoint almost always means a stale token
|
|
311
|
+
# is being sent. Name the offender so the user knows where to look.
|
|
312
|
+
which = []
|
|
313
|
+
if "gitlab" in str(exc).lower() and os.environ.get("GITLAB_TOKEN"):
|
|
314
|
+
which.append("GITLAB_TOKEN")
|
|
315
|
+
if "github" in str(exc).lower() and os.environ.get("GITHUB_TOKEN"):
|
|
316
|
+
which.append("GITHUB_TOKEN")
|
|
317
|
+
if not which:
|
|
318
|
+
for var in ("GITLAB_TOKEN", "GITHUB_TOKEN"):
|
|
319
|
+
if os.environ.get(var):
|
|
320
|
+
which.append(var)
|
|
321
|
+
logger.error(f"Failed to install '{args.package}': 401 Unauthorized")
|
|
322
|
+
if which:
|
|
323
|
+
tokens = " / ".join(which)
|
|
324
|
+
msg = f"Hint: {tokens} is set but rejected — likely expired or revoked."
|
|
325
|
+
logger.error(f" {dim(msg)}")
|
|
326
|
+
logger.error(f" {dim(f' Try: unset {which[0]} (or rotate the token)')}")
|
|
327
|
+
else:
|
|
328
|
+
logger.error(f" {dim('Hint: API rejected — check the project URL is public.')}")
|
|
329
|
+
elif is_not_found:
|
|
330
|
+
logger.error(f"Failed to install '{args.package}': not found")
|
|
331
|
+
_log_not_found_hint(logger, args.package)
|
|
332
|
+
elif is_no_asset:
|
|
333
|
+
logger.error(f"Failed to install '{args.package}': {exc}")
|
|
334
|
+
logger.error(f" {dim('Hint: prefix the spec to force a backend (e.g. @gh:owner/repo)')}")
|
|
335
|
+
else:
|
|
336
|
+
logger.error(f"Failed to install '{args.package}': {exc}")
|
|
337
|
+
|
|
338
|
+
logger.debug(f"raw error: {type(exc).__name__}: {exc}")
|
|
339
|
+
if body:
|
|
340
|
+
logger.debug(f"response body: {body}")
|
|
341
|
+
return 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _save_install_to_toml(args: argparse.Namespace, record: ToolRecord, logger) -> None:
|
|
345
|
+
"""Persist the just-installed tool into ixt.toml."""
|
|
346
|
+
from ixt.core.save import (
|
|
347
|
+
extract_version_from_spec,
|
|
348
|
+
save_tool,
|
|
349
|
+
tool_entry_key,
|
|
350
|
+
tool_install_spec,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
bt = BackendType(record.backend)
|
|
354
|
+
entry_key = tool_entry_key(record.name, record.spec, bt)
|
|
355
|
+
entry_install = None
|
|
356
|
+
entry_slot = _saved_entry_slot(entry_key, record)
|
|
357
|
+
if entry_slot is not None:
|
|
358
|
+
entry_key = entry_slot
|
|
359
|
+
entry_install = tool_install_spec(record.spec, bt)
|
|
360
|
+
# Only persist ``expose`` when it differs from the implicit default
|
|
361
|
+
# (``[__main__]``) — a bare install writes ``expose = []`` so apply can
|
|
362
|
+
# reproduce it faithfully.
|
|
363
|
+
expose_out: list[str] | None
|
|
364
|
+
if record.expose_rules == [EXPOSE_MAIN]:
|
|
365
|
+
expose_out = None
|
|
366
|
+
else:
|
|
367
|
+
expose_out = list(record.expose_rules)
|
|
368
|
+
save_tool(
|
|
369
|
+
entry_key,
|
|
370
|
+
install=entry_install,
|
|
371
|
+
version=extract_version_from_spec(args.package, bt),
|
|
372
|
+
expose=expose_out,
|
|
373
|
+
node_shim=record.node_shim,
|
|
374
|
+
)
|
|
375
|
+
logger.info(f" {dim('saved to ixt.toml')}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _saved_entry_slot(entry_key: str, record: ToolRecord) -> str | None:
|
|
379
|
+
from ixt.core.identity import slot_from_id
|
|
380
|
+
from ixt.core.install import plan_install
|
|
381
|
+
|
|
382
|
+
base_id = plan_install(entry_key, settings=get_settings()).tool_name
|
|
383
|
+
return slot_from_id(record.name, base_id)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
387
|
+
logger = get_logger("uninstall")
|
|
388
|
+
dry_run = getattr(args, "dry_run", False)
|
|
389
|
+
yes = getattr(args, "yes", False)
|
|
390
|
+
settings = get_settings()
|
|
391
|
+
|
|
392
|
+
if args.uninstall_all:
|
|
393
|
+
return _cmd_uninstall_all(logger, settings, dry_run=dry_run, yes=yes)
|
|
394
|
+
return _cmd_uninstall_one(args, logger, settings, dry_run=dry_run)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _cmd_uninstall_all(logger, settings, *, dry_run: bool, yes: bool) -> int:
|
|
398
|
+
meta_files = settings.iter_installed_metadata()
|
|
399
|
+
if not meta_files:
|
|
400
|
+
logger.info(f"{dim('[dry-run]')} no tools installed" if dry_run else "No tools installed")
|
|
401
|
+
return 0
|
|
402
|
+
records = [ToolRecord.load_json(mf) for mf in meta_files]
|
|
403
|
+
records_by_id = {record.name: record for record in records}
|
|
404
|
+
|
|
405
|
+
if dry_run:
|
|
406
|
+
logger.info(f"{dim('[dry-run]')} would remove {len(meta_files)} tool(s):")
|
|
407
|
+
for record in records:
|
|
408
|
+
bins = ", ".join(sorted(record.exposed_bins)) or dim("no bins")
|
|
409
|
+
display = bold(display_tool_name(record, settings))
|
|
410
|
+
logger.info(f" {red('-')} {display} {dim('bins:')} {bins}")
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
if not yes:
|
|
414
|
+
names = [display_tool_name(record, settings) for record in records]
|
|
415
|
+
confirmed = _confirm_uninstall_all(logger, names)
|
|
416
|
+
if confirmed is None:
|
|
417
|
+
return 1
|
|
418
|
+
if not confirmed:
|
|
419
|
+
logger.info("aborted by user")
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
from ixt.core.install import uninstall_all
|
|
423
|
+
|
|
424
|
+
results = uninstall_all(settings=settings)
|
|
425
|
+
|
|
426
|
+
errors = 0
|
|
427
|
+
for name, result in results.items():
|
|
428
|
+
display = (
|
|
429
|
+
display_tool_name(records_by_id[name], settings) if name in records_by_id else name
|
|
430
|
+
)
|
|
431
|
+
if isinstance(result, Exception):
|
|
432
|
+
logger.error(f" {red('x')} {display}: {result}")
|
|
433
|
+
errors += 1
|
|
434
|
+
else:
|
|
435
|
+
logger.info(f" {red('-')} {bold(display)}")
|
|
436
|
+
return 1 if errors else 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _confirm_uninstall_all(logger, names: list[str]) -> bool | None:
|
|
440
|
+
"""Prompt before deleting every installed tool."""
|
|
441
|
+
import sys
|
|
442
|
+
|
|
443
|
+
summary = f"About to remove {len(names)} tool(s): {', '.join(sorted(names))}"
|
|
444
|
+
if not sys.stdin.isatty():
|
|
445
|
+
logger.error(summary)
|
|
446
|
+
logger.error(f" {dim('non-interactive session; pass --yes to confirm')}")
|
|
447
|
+
return None
|
|
448
|
+
print(summary, file=sys.stderr)
|
|
449
|
+
try:
|
|
450
|
+
answer = input("Proceed? [y/N] ").strip().lower()
|
|
451
|
+
except (EOFError, KeyboardInterrupt):
|
|
452
|
+
print("", file=sys.stderr)
|
|
453
|
+
return False
|
|
454
|
+
return answer in ("y", "yes")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _cmd_uninstall_one(args: argparse.Namespace, logger, settings, *, dry_run: bool) -> int:
|
|
458
|
+
if not args.target:
|
|
459
|
+
logger.error("Specify a tool name or use --all")
|
|
460
|
+
return 1
|
|
461
|
+
|
|
462
|
+
from ixt.core.install import uninstall_tool
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
tool_name = resolve_tool_arg(args.target, settings=settings)
|
|
466
|
+
except ValueError as e:
|
|
467
|
+
logger.error(str(e))
|
|
468
|
+
return 1
|
|
469
|
+
|
|
470
|
+
meta_file = settings.get_tool_metadata_file(tool_name)
|
|
471
|
+
env_dir = settings.get_tool_env_dir(tool_name)
|
|
472
|
+
record = ToolRecord.load_json(meta_file) if meta_file.exists() else None
|
|
473
|
+
display = display_tool_name(record, settings) if record else tool_name
|
|
474
|
+
|
|
475
|
+
if dry_run:
|
|
476
|
+
if not env_dir.exists():
|
|
477
|
+
logger.error(f"Tool '{args.target}' is not installed")
|
|
478
|
+
logger.error(f" {dim('Hint: run ixt tool list to see installed tools')}")
|
|
479
|
+
return 1
|
|
480
|
+
logger.info(f"{dim('[dry-run]')} would remove:")
|
|
481
|
+
logger.info(f" {red('-')} {bold(display)}")
|
|
482
|
+
logger.info(f" env: {dim(str(env_dir))}")
|
|
483
|
+
if record:
|
|
484
|
+
if record.exposed_bins:
|
|
485
|
+
logger.info(" unexpose:")
|
|
486
|
+
for name, source in sorted(record.exposed_bins.items()):
|
|
487
|
+
logger.info(f" {cyan(name)} {dim('->')} {dim(source)}")
|
|
488
|
+
else:
|
|
489
|
+
logger.info(f" unexpose: {dim('none')}")
|
|
490
|
+
if args.save:
|
|
491
|
+
logger.info(f" {dim('would remove from ixt.toml')}")
|
|
492
|
+
return 0
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
uninstall_tool(tool_name)
|
|
496
|
+
except FileNotFoundError:
|
|
497
|
+
logger.error(f"Tool '{args.target}' is not installed")
|
|
498
|
+
logger.error(f" {dim('Hint: run ixt tool list to see installed tools')}")
|
|
499
|
+
return 1
|
|
500
|
+
|
|
501
|
+
logger.info(f" {red('-')} {bold(display)}")
|
|
502
|
+
|
|
503
|
+
if args.save:
|
|
504
|
+
from ixt.core.save import remove_tool
|
|
505
|
+
|
|
506
|
+
remove_tool(tool_name)
|
|
507
|
+
logger.info(f" {dim('removed from ixt.toml')}")
|
|
508
|
+
return 0
|