ixt-cli 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. ixt/__init__.py +8 -0
  2. ixt/__main__.py +8 -0
  3. ixt/backends/__init__.py +1 -0
  4. ixt/backends/binary.py +935 -0
  5. ixt/backends/binary_resolver.py +307 -0
  6. ixt/backends/node.py +490 -0
  7. ixt/backends/python.py +234 -0
  8. ixt/cli/__init__.py +31 -0
  9. ixt/cli/argparse_completion.py +557 -0
  10. ixt/cli/cmd_apply.py +404 -0
  11. ixt/cli/cmd_cache.py +86 -0
  12. ixt/cli/cmd_config.py +295 -0
  13. ixt/cli/cmd_info.py +116 -0
  14. ixt/cli/cmd_install.py +508 -0
  15. ixt/cli/cmd_misc.py +261 -0
  16. ixt/cli/cmd_registry.py +35 -0
  17. ixt/cli/cmd_upgrade.py +336 -0
  18. ixt/cli/commands.py +70 -0
  19. ixt/cli/parser.py +555 -0
  20. ixt/cli/render.py +85 -0
  21. ixt/config/__init__.py +5 -0
  22. ixt/config/asset_index.py +305 -0
  23. ixt/config/asset_pattern_cache.py +87 -0
  24. ixt/config/env_policy.py +340 -0
  25. ixt/config/flags.py +29 -0
  26. ixt/config/fs_policy.py +17 -0
  27. ixt/config/heuristics.py +465 -0
  28. ixt/config/models.py +176 -0
  29. ixt/config/registry.py +145 -0
  30. ixt/config/settings.py +173 -0
  31. ixt/config/setup_toml.py +179 -0
  32. ixt/config/toml.py +416 -0
  33. ixt/core/__init__.py +16 -0
  34. ixt/core/apply.py +564 -0
  35. ixt/core/apply_actions.py +106 -0
  36. ixt/core/backend.py +187 -0
  37. ixt/core/bootstrap.py +410 -0
  38. ixt/core/cache.py +332 -0
  39. ixt/core/discover.py +150 -0
  40. ixt/core/doctor.py +591 -0
  41. ixt/core/export.py +419 -0
  42. ixt/core/expose.py +350 -0
  43. ixt/core/extract.py +261 -0
  44. ixt/core/hooks.py +182 -0
  45. ixt/core/identity.py +148 -0
  46. ixt/core/inject.py +143 -0
  47. ixt/core/install.py +509 -0
  48. ixt/core/install_local.py +229 -0
  49. ixt/core/locks.py +54 -0
  50. ixt/core/pathlink.py +86 -0
  51. ixt/core/resolution_stats.py +191 -0
  52. ixt/core/resolve.py +150 -0
  53. ixt/core/resolve_cache.py +185 -0
  54. ixt/core/runtimes.py +192 -0
  55. ixt/core/save.py +237 -0
  56. ixt/core/setup_completions.py +11 -0
  57. ixt/core/setup_path.py +368 -0
  58. ixt/core/upgrade.py +596 -0
  59. ixt/data/__init__.py +10 -0
  60. ixt/data/asset_index.json +574 -0
  61. ixt/data/heuristics.toml +98 -0
  62. ixt/data/registry.toml +71 -0
  63. ixt/libs/__init__.py +3 -0
  64. ixt/libs/constants.py +4 -0
  65. ixt/libs/fmt.py +108 -0
  66. ixt/libs/logger.py +109 -0
  67. ixt/libs/output.py +25 -0
  68. ixt/libs/req_spec.py +115 -0
  69. ixt/libs/semver.py +149 -0
  70. ixt/libs/shell.py +126 -0
  71. ixt/libs/style.py +238 -0
  72. ixt/net/__init__.py +1 -0
  73. ixt/net/github_api.py +158 -0
  74. ixt/net/gitlab_api.py +149 -0
  75. ixt/net/http.py +194 -0
  76. ixt/net/npm.py +24 -0
  77. ixt/net/pypi.py +26 -0
  78. ixt/net/source.py +163 -0
  79. ixt/platform/__init__.py +131 -0
  80. ixt/platform/win.py +68 -0
  81. ixt_cli-0.8.0.dist-info/METADATA +294 -0
  82. ixt_cli-0.8.0.dist-info/RECORD +84 -0
  83. ixt_cli-0.8.0.dist-info/WHEEL +4 -0
  84. ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/core/expose.py ADDED
@@ -0,0 +1,350 @@
1
+ """Exposure engine — resolve rules and link binaries to the shared bin dir."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from ixt.config.heuristics import get_name_suffix_tokens, get_platform_aliases
10
+ from ixt.core.backend import Backend
11
+ from ixt.core.pathlink import PathLink, create_path_link
12
+ from ixt.libs.constants import EXPOSE_MAIN
13
+
14
+ # Binaries that should never be exposed (runtime internals).
15
+ _EXCLUDED_RE = re.compile(r"^(python|pythonw|pip)(\d+(\.\d+)?)?(.exe)?$")
16
+ _PLATFORM_SEPARATORS = "._-"
17
+
18
+
19
+ def _strip_exe_suffix(name: str) -> str:
20
+ """Return *name* without the Windows executable suffix, if present."""
21
+ return name[:-4] if name.lower().endswith(".exe") else name
22
+
23
+
24
+ def _executable_name(path: Path) -> str:
25
+ """Return the executable name used for rule matching."""
26
+ return _strip_exe_suffix(path.name)
27
+
28
+
29
+ def _strip_suffix_tokens(name: str) -> str:
30
+ """Remove OS/arch/build suffixes when they appear as trailing full tokens."""
31
+ os_aliases, arch_aliases = get_platform_aliases()
32
+ aliases = {
33
+ alias.lower()
34
+ for alias in [*os_aliases, *arch_aliases]
35
+ if alias and alias.strip(_PLATFORM_SEPARATORS)
36
+ }
37
+ aliases.update(
38
+ token.lower()
39
+ for token in get_name_suffix_tokens()
40
+ if token and token.strip(_PLATFORM_SEPARATORS)
41
+ )
42
+ stripped = name.strip(_PLATFORM_SEPARATORS)
43
+ changed = True
44
+ while stripped and changed:
45
+ changed = False
46
+ for alias in sorted(aliases, key=len, reverse=True):
47
+ token_re = re.compile(
48
+ rf"(?:^|[{re.escape(_PLATFORM_SEPARATORS)}])"
49
+ rf"{re.escape(alias)}$",
50
+ re.IGNORECASE,
51
+ )
52
+ next_stripped = token_re.sub("", stripped).strip(_PLATFORM_SEPARATORS)
53
+ if next_stripped != stripped:
54
+ stripped = next_stripped
55
+ changed = True
56
+ break
57
+ return stripped
58
+
59
+
60
+ def _eponym_alias(name: str, package_name: str) -> str | None:
61
+ """Return *package_name* as alias when *name* is a suffixed variant.
62
+
63
+ Only aliases when removing the current suffix tokens leaves exactly
64
+ *package_name*. Returns None when *name* already equals *package_name*.
65
+ """
66
+ if name == package_name:
67
+ return None
68
+ if _strip_suffix_tokens(name).lower() == package_name.lower():
69
+ return package_name
70
+ return None
71
+
72
+
73
+ def _is_excluded(name: str) -> bool:
74
+ """Return True if *name* is a runtime binary that must not be exposed."""
75
+ return bool(_EXCLUDED_RE.match(name))
76
+
77
+
78
+ def _parse_rename(rule: str) -> tuple[str, str | None]:
79
+ """Parse ``"original:alias"`` → ``("original", "alias")``.
80
+
81
+ Plain names return ``("name", None)``.
82
+ """
83
+ if ":" in rule:
84
+ orig, alias = rule.split(":", 1)
85
+ return orig.strip(), alias.strip()
86
+ return rule.strip(), None
87
+
88
+
89
+ # ------------------------------------------------------------------
90
+ # Expose result
91
+ # ------------------------------------------------------------------
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class ExposeResult:
96
+ """Outcome of an expose operation for one tool."""
97
+
98
+ linked: dict[str, str] = field(default_factory=dict)
99
+ """Map of exposed_name → source_path for successfully linked binaries."""
100
+
101
+ skipped: dict[str, str] = field(default_factory=dict)
102
+ """Map of name → reason for binaries that were skipped."""
103
+
104
+
105
+ # ------------------------------------------------------------------
106
+ # Rule resolver
107
+ # ------------------------------------------------------------------
108
+
109
+
110
+ def _resolve_eponym(
111
+ package_name: str,
112
+ all_bins: list[Path],
113
+ ) -> list[tuple[Path, str | None]]:
114
+ """Find the binary matching *package_name* exactly or after platform-token cleanup."""
115
+ for p in all_bins:
116
+ if _executable_name(p) == package_name:
117
+ return [(p, None)]
118
+ for p in all_bins:
119
+ alias = _eponym_alias(_executable_name(p), package_name)
120
+ if alias is not None:
121
+ return [(p, alias)]
122
+ return []
123
+
124
+
125
+ def _resolve_main(
126
+ package_name: str,
127
+ backend: Backend,
128
+ env_dir: Path,
129
+ all_bins: list[Path],
130
+ ) -> list[tuple[Path, str | None]]:
131
+ """Resolve ``__main__`` — declared console_scripts / bin entries."""
132
+ declared = backend.get_package_binaries(env_dir, package_name)
133
+ if not declared:
134
+ return []
135
+ bin_dir_entries = {_executable_name(p): p for p in all_bins}
136
+ result: list[tuple[Path, str | None]] = []
137
+ for name in declared:
138
+ executable_name = _strip_exe_suffix(name)
139
+ if _is_excluded(executable_name):
140
+ continue
141
+ if executable_name in bin_dir_entries:
142
+ result.append(
143
+ (
144
+ bin_dir_entries[executable_name],
145
+ _eponym_alias(executable_name, package_name),
146
+ )
147
+ )
148
+ return result
149
+
150
+
151
+ def _resolve_all(
152
+ all_bins: list[Path],
153
+ ) -> list[tuple[Path, str | None]]:
154
+ """Resolve ``__all__`` — every binary except python/pip."""
155
+ return [(p, None) for p in all_bins if not _is_excluded(_executable_name(p))]
156
+
157
+
158
+ def _resolve_explicit(
159
+ rules: list[str],
160
+ all_bins: list[Path],
161
+ ) -> list[tuple[Path, str | None]]:
162
+ """Resolve an explicit list of names, with optional rename syntax."""
163
+ bin_map = {_executable_name(p): p for p in all_bins}
164
+ result: list[tuple[Path, str | None]] = []
165
+ for rule in rules:
166
+ orig, alias = _parse_rename(rule)
167
+ executable_name = _strip_exe_suffix(orig)
168
+ if executable_name in bin_map:
169
+ result.append((bin_map[executable_name], alias))
170
+ return result
171
+
172
+
173
+ def resolve_rules(
174
+ rules: list[str],
175
+ package_name: str,
176
+ backend: Backend,
177
+ env_dir: Path,
178
+ ) -> list[tuple[Path, str | None]]:
179
+ """Resolve expose *rules* into a list of ``(source_path, alias_or_None)``.
180
+
181
+ Implements the fallback chain when the first rule is a single keyword:
182
+ ``__eponym__`` → ``__main__`` → ``__eponym__`` retry → ``__all__``
183
+ """
184
+ all_bins = backend.find_binaries(env_dir)
185
+
186
+ # Special keyword rules (single-element list with a dunder keyword).
187
+ if len(rules) == 1 and rules[0].startswith("__"):
188
+ keyword = rules[0]
189
+
190
+ if keyword == "__all__":
191
+ return _resolve_all(all_bins)
192
+
193
+ if keyword == EXPOSE_MAIN:
194
+ result = _resolve_main(package_name, backend, env_dir, all_bins)
195
+ if result:
196
+ return result
197
+ # fallback → __eponym__
198
+ result = _resolve_eponym(package_name, all_bins)
199
+ if result:
200
+ return result
201
+ # fallback → __all__
202
+ return _resolve_all(all_bins)
203
+
204
+ if keyword == "__eponym__":
205
+ result = _resolve_eponym(package_name, all_bins)
206
+ if result:
207
+ return result
208
+ # fallback → __main__
209
+ result = _resolve_main(package_name, backend, env_dir, all_bins)
210
+ if result:
211
+ return result
212
+ # fallback → __all__
213
+ return _resolve_all(all_bins)
214
+
215
+ # Explicit list (possibly with rename syntax).
216
+ return _resolve_explicit(rules, all_bins)
217
+
218
+
219
+ # ------------------------------------------------------------------
220
+ # Public API
221
+ # ------------------------------------------------------------------
222
+
223
+
224
+ def expose_tool(
225
+ package_name: str,
226
+ backend: Backend,
227
+ env_dir: Path,
228
+ bin_dir: Path,
229
+ rules: list[str] | None = None,
230
+ *,
231
+ overwrite: bool = False,
232
+ ) -> ExposeResult:
233
+ """Expose binaries for one tool according to *rules*.
234
+
235
+ Returns an `ExposeResult` describing what was linked and what was skipped.
236
+ Collision reasons include the owning tool name when available.
237
+ """
238
+ rules = rules or [EXPOSE_MAIN]
239
+ resolved = resolve_rules(rules, package_name, backend, env_dir)
240
+ result = ExposeResult()
241
+
242
+ bin_dir.mkdir(parents=True, exist_ok=True)
243
+
244
+ for source, alias in resolved:
245
+ link = create_path_link(source, bin_dir, rename_to=alias)
246
+ exposed_name = alias or _executable_name(source)
247
+
248
+ if link.target_exists() and not overwrite:
249
+ # Check if it's our own (idempotent re-expose).
250
+ if link.is_valid():
251
+ result.linked[exposed_name] = str(source)
252
+ else:
253
+ owner = _find_shim_owner(exposed_name, bin_dir, exclude_env_dir=env_dir)
254
+ reason = (
255
+ f"conflict: already exposed by {owner}" if owner else "conflict: already exists"
256
+ )
257
+ result.skipped[exposed_name] = reason
258
+ continue
259
+
260
+ link.create(overwrite=overwrite)
261
+ result.linked[exposed_name] = str(source)
262
+
263
+ return result
264
+
265
+
266
+ def warn_if_skipped(result: ExposeResult, tool_name: str, logger) -> None:
267
+ """Log a warning and an actionable hint when expose produced collisions."""
268
+ if not result.skipped:
269
+ return
270
+ if len(result.skipped) == 1:
271
+ name, reason = next(iter(sorted(result.skipped.items())))
272
+ logger.warn(f"could not expose: {name} ({reason})")
273
+ else:
274
+ logger.warn("could not expose:")
275
+ for name, reason in sorted(result.skipped.items()):
276
+ logger.warn(f" {name}: {reason}")
277
+ example = next(iter(sorted(result.skipped)))
278
+ logger.warn(f" Hint: run ixt tool config {tool_name} expose {example}:<alias>")
279
+
280
+
281
+ def _find_shim_owner(exposed_name: str, bin_dir: Path, *, exclude_env_dir: Path) -> str | None:
282
+ """Return the name of the tool whose record claims *exposed_name*, or None.
283
+
284
+ Scans installed tool metadata under ``bin_dir.parent / "envs"``. Skips
285
+ ``exclude_env_dir`` so a reinstall doesn't report itself as the owner.
286
+ """
287
+ envs_dir = bin_dir.parent / "envs"
288
+ if not envs_dir.is_dir():
289
+ return None
290
+ exclude_resolved = exclude_env_dir.resolve() if exclude_env_dir.exists() else exclude_env_dir
291
+ from ixt.config.models import ToolRecord
292
+
293
+ for env_path in envs_dir.iterdir():
294
+ if not env_path.is_dir():
295
+ continue
296
+ if env_path.resolve() == exclude_resolved:
297
+ continue
298
+ meta = env_path / "ixt.json"
299
+ if not meta.is_file():
300
+ continue
301
+ try:
302
+ record = ToolRecord.load_json(meta)
303
+ except Exception:
304
+ continue
305
+ if exposed_name in record.exposed_bins:
306
+ return record.name
307
+ return None
308
+
309
+
310
+ def unexpose_tool(
311
+ exposed_bins: dict[str, str],
312
+ bin_dir: Path,
313
+ ) -> list[str]:
314
+ """Remove previously exposed links for one tool.
315
+
316
+ *exposed_bins* maps ``exposed_name → source_path`` (from ``ToolRecord``).
317
+ Returns the list of names successfully removed.
318
+ """
319
+ removed: list[str] = []
320
+ for exposed_name, source_str in exposed_bins.items():
321
+ link = PathLink(
322
+ source_path=Path(source_str),
323
+ target_path=bin_dir / exposed_name,
324
+ )
325
+ if link.remove(force=True):
326
+ removed.append(exposed_name)
327
+ return removed
328
+
329
+
330
+ def reexpose_tool(record, new_rules: list[str], settings) -> ExposeResult:
331
+ """Unexpose then re-expose *record* with *new_rules*, and persist it."""
332
+ from ixt.core.backend import BackendType, get_backend
333
+
334
+ env_dir = Path(record.env_dir)
335
+ bt = BackendType(record.backend)
336
+ backend = get_backend(bt, settings=settings)
337
+
338
+ unexpose_tool(record.exposed_bins, settings.bin_dir)
339
+ result = expose_tool(
340
+ record.pkg(),
341
+ backend,
342
+ env_dir,
343
+ settings.bin_dir,
344
+ new_rules,
345
+ overwrite=True,
346
+ )
347
+ record.expose_rules = list(new_rules)
348
+ record.exposed_bins = result.linked
349
+ record.save_json(settings.get_tool_metadata_file(record.name))
350
+ return result
ixt/core/extract.py ADDED
@@ -0,0 +1,261 @@
1
+ """Archive extraction with anti-traversal protection.
2
+
3
+ Supports: .tar.gz, .tar.xz, .tar.bz2, .tgz, .txz, .zip, bare binaries.
4
+ All stdlib — no third-party dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import stat
12
+ import tarfile
13
+ import zipfile
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ TarReadMode = Literal["r:gz", "r:xz", "r:bz2"]
19
+
20
+
21
+ class ExtractionError(Exception):
22
+ """Raised on extraction failure (bad archive, traversal attempt, etc.)."""
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class ExtractionResult:
27
+ """Outcome of an extraction operation."""
28
+
29
+ root: Path
30
+ """The effective root directory containing the extracted content."""
31
+
32
+ files: list[Path]
33
+ """All extracted file paths (relative to *root*)."""
34
+
35
+ is_bare_binary: bool = False
36
+ """True when the asset was a bare binary (no archive)."""
37
+
38
+
39
+ # -- Archive format detection -------------------------------------------------
40
+
41
+ _TAR_EXTENSIONS: dict[str, TarReadMode] = {
42
+ ".tar.gz": "r:gz",
43
+ ".tgz": "r:gz",
44
+ ".tar.xz": "r:xz",
45
+ ".txz": "r:xz",
46
+ ".tar.bz2": "r:bz2",
47
+ }
48
+
49
+ _ZIP_EXTENSIONS = {".zip"}
50
+
51
+
52
+ def _archive_mode(filename: str) -> TarReadMode | None:
53
+ """Return tarfile open mode if *filename* is a supported tar, else None."""
54
+ lower = filename.lower()
55
+ for ext, mode in _TAR_EXTENSIONS.items():
56
+ if lower.endswith(ext):
57
+ return mode
58
+ return None
59
+
60
+
61
+ def _is_zip(filename: str) -> bool:
62
+ return filename.lower().endswith(".zip")
63
+
64
+
65
+ # -- Anti-traversal -----------------------------------------------------------
66
+
67
+
68
+ def _safe_path(member_name: str, dest: Path) -> Path:
69
+ """Resolve *member_name* under *dest* and reject path traversal.
70
+
71
+ Raises ExtractionError if the resolved path escapes *dest*.
72
+ """
73
+ # Normalise: strip leading / and ./ , collapse ..
74
+ cleaned = os.path.normpath(member_name)
75
+ if cleaned.startswith(("../", "..\\")):
76
+ raise ExtractionError(f"Path traversal detected: {member_name!r}")
77
+
78
+ resolved = (dest / cleaned).resolve()
79
+ dest_resolved = dest.resolve()
80
+
81
+ if not str(resolved).startswith(str(dest_resolved) + os.sep) and resolved != dest_resolved:
82
+ raise ExtractionError(f"Path traversal detected: {member_name!r} -> {resolved}")
83
+
84
+ return resolved
85
+
86
+
87
+ # -- Tar extraction -----------------------------------------------------------
88
+
89
+
90
+ def _extract_tar(archive_path: Path, dest: Path, mode: TarReadMode) -> list[Path]:
91
+ """Extract a tar archive, returning list of extracted files (relative)."""
92
+ files: list[Path] = []
93
+
94
+ with tarfile.open(archive_path, mode) as tf:
95
+ for member in tf.getmembers():
96
+ # Skip special file types (devices, etc.)
97
+ if not (member.isfile() or member.isdir() or member.issym()):
98
+ continue
99
+
100
+ target = _safe_path(member.name, dest)
101
+
102
+ if member.isdir():
103
+ target.mkdir(parents=True, exist_ok=True)
104
+ elif member.issym():
105
+ # Validate symlink target stays within dest
106
+ link_target = os.path.normpath(
107
+ os.path.join(os.path.dirname(member.name), member.linkname)
108
+ )
109
+ _safe_path(link_target, dest)
110
+ target.parent.mkdir(parents=True, exist_ok=True)
111
+ if target.exists() or target.is_symlink():
112
+ target.unlink()
113
+ os.symlink(member.linkname, target)
114
+ else:
115
+ # Regular file
116
+ target.parent.mkdir(parents=True, exist_ok=True)
117
+ src = tf.extractfile(member)
118
+ if src is None:
119
+ continue
120
+ with src, target.open("wb") as dst:
121
+ shutil.copyfileobj(src, dst)
122
+
123
+ # Preserve executable bit
124
+ if member.mode & 0o111:
125
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
126
+
127
+ files.append(Path(member.name))
128
+
129
+ return files
130
+
131
+
132
+ # -- Zip extraction -----------------------------------------------------------
133
+
134
+
135
+ def _extract_zip(archive_path: Path, dest: Path) -> list[Path]:
136
+ """Extract a zip archive, returning list of extracted files (relative)."""
137
+ files: list[Path] = []
138
+
139
+ with zipfile.ZipFile(archive_path, "r") as zf:
140
+ for info in zf.infolist():
141
+ target = _safe_path(info.filename, dest)
142
+
143
+ if info.is_dir():
144
+ target.mkdir(parents=True, exist_ok=True)
145
+ continue
146
+
147
+ target.parent.mkdir(parents=True, exist_ok=True)
148
+
149
+ with zf.open(info) as src, target.open("wb") as dst:
150
+ shutil.copyfileobj(src, dst)
151
+
152
+ # Preserve Unix executable bit from external_attr
153
+ unix_mode = info.external_attr >> 16
154
+ if unix_mode & 0o111:
155
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
156
+
157
+ files.append(Path(info.filename))
158
+
159
+ return files
160
+
161
+
162
+ # -- Bare binary --------------------------------------------------------------
163
+
164
+
165
+ def _copy_bare_binary(src: Path, dest: Path, name: str) -> list[Path]:
166
+ """Copy a bare binary (no archive) into *dest* and make it executable."""
167
+ target = _safe_path(name, dest)
168
+ target.parent.mkdir(parents=True, exist_ok=True)
169
+ with src.open("rb") as source, target.open("wb") as out:
170
+ shutil.copyfileobj(source, out)
171
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
172
+ return [Path(name)]
173
+
174
+
175
+ # -- Root normalisation -------------------------------------------------------
176
+
177
+
178
+ def _normalise_root(dest: Path, files: list[Path]) -> tuple[Path, list[Path]]:
179
+ """If all files share a single top-level directory, descend into it.
180
+
181
+ Many archives wrap everything in a directory like ``ripgrep-14.1.0/``.
182
+ This returns the effective root and rebased file paths.
183
+ """
184
+ if not files:
185
+ return dest, files
186
+
187
+ # Collect unique top-level components
188
+ top_dirs: set[str] = set()
189
+ for f in files:
190
+ top_dirs.add(f.parts[0])
191
+
192
+ if len(top_dirs) != 1:
193
+ return dest, files
194
+
195
+ # Single top-level directory — check it's actually a directory
196
+ wrapper = top_dirs.pop()
197
+ wrapper_path = dest / wrapper
198
+ if not wrapper_path.is_dir():
199
+ return dest, files
200
+
201
+ # Rebase files relative to the wrapper
202
+ rebased: list[Path] = []
203
+ for f in files:
204
+ parts = f.parts[1:] # strip wrapper prefix
205
+ if parts:
206
+ rebased.append(Path(*parts))
207
+
208
+ return wrapper_path, rebased
209
+
210
+
211
+ # -- Public API ---------------------------------------------------------------
212
+
213
+
214
+ def is_archive(filename: str) -> bool:
215
+ """Return True if *filename* has a recognized archive extension."""
216
+ return _archive_mode(filename) is not None or _is_zip(filename)
217
+
218
+
219
+ def extract(
220
+ archive_path: Path,
221
+ dest: Path,
222
+ *,
223
+ tool_name: str | None = None,
224
+ ) -> ExtractionResult:
225
+ """Extract an archive or copy a bare binary into *dest*.
226
+
227
+ Args:
228
+ archive_path: Path to the downloaded archive or bare binary.
229
+ dest: Directory to extract into (created if needed).
230
+ tool_name: Tool name used as filename for bare binaries.
231
+
232
+ Returns:
233
+ ExtractionResult with the effective root and file listing.
234
+
235
+ Raises:
236
+ ExtractionError: On path traversal, bad archive, or I/O failure.
237
+ """
238
+ dest.mkdir(parents=True, exist_ok=True)
239
+ name = archive_path.name
240
+
241
+ tar_mode = _archive_mode(name)
242
+ if tar_mode is not None:
243
+ try:
244
+ files = _extract_tar(archive_path, dest, tar_mode)
245
+ except (tarfile.TarError, OSError) as exc:
246
+ raise ExtractionError(f"Failed to extract tar: {exc}") from exc
247
+ root, files = _normalise_root(dest, files)
248
+ return ExtractionResult(root=root, files=files)
249
+
250
+ if _is_zip(name):
251
+ try:
252
+ files = _extract_zip(archive_path, dest)
253
+ except (zipfile.BadZipFile, OSError) as exc:
254
+ raise ExtractionError(f"Failed to extract zip: {exc}") from exc
255
+ root, files = _normalise_root(dest, files)
256
+ return ExtractionResult(root=root, files=files)
257
+
258
+ # Bare binary — not an archive
259
+ bin_name = tool_name or archive_path.stem
260
+ files = _copy_bare_binary(archive_path, dest, bin_name)
261
+ return ExtractionResult(root=dest, files=files, is_bare_binary=True)