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/backends/node.py ADDED
@@ -0,0 +1,490 @@
1
+ """Node backend — isolated node_modules per tool via bun (npm fallback).
2
+
3
+ Binaries are discovered from ``node_modules/.bin/`` which is the ecosystem's
4
+ source of truth — bun and npm both populate it with correct shebangs, NODE_PATH,
5
+ and entries from postinstall scripts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from ixt.config.settings import Settings, get_settings
18
+ from ixt.core.backend import BackendType
19
+ from ixt.libs.logger import get_logger
20
+ from ixt.libs.shell import ShellError, command_exists, shell_run
21
+ from ixt.platform import is_windows
22
+
23
+ # Matches `node` as a standalone token in a shebang line:
24
+ # #!/usr/bin/env node → match
25
+ # #!/usr/bin/env -S node --harmony → match
26
+ # #!/usr/bin/nodejs → no match (false positive avoided)
27
+ # #!/usr/bin/env nodemon → no match
28
+ _NODE_SHEBANG_RE = re.compile(rb"^#!.*\bnode\b")
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # npm spec helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def parse_npm_spec(spec: str) -> tuple[str, str | None]:
36
+ """Parse an npm package spec into (name, version_or_None).
37
+
38
+ Handles:
39
+ - ``@scope/pkg@1.2.3`` → (``@scope/pkg``, ``1.2.3``)
40
+ - ``@scope/pkg`` → (``@scope/pkg``, None)
41
+ - ``pkg@^5.0`` → (``pkg``, ``^5.0``)
42
+ - ``pkg`` → (``pkg``, None)
43
+ """
44
+ s = spec.strip()
45
+ if s.startswith("@"):
46
+ # Scoped: find the second @ (version separator)
47
+ rest = s[1:]
48
+ if "/" not in rest:
49
+ return s, None
50
+ slash_idx = rest.index("/")
51
+ after_slash = rest[slash_idx + 1 :]
52
+ if "@" in after_slash:
53
+ at_idx = after_slash.index("@")
54
+ name = s[: 1 + slash_idx + 1 + at_idx]
55
+ version = after_slash[at_idx + 1 :]
56
+ return name, version or None
57
+ return s, None
58
+ # Unscoped
59
+ if "@" in s:
60
+ name, version = s.split("@", 1)
61
+ return name, version or None
62
+ return s, None
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Metadata
67
+ # ---------------------------------------------------------------------------
68
+
69
+ _META_FILE = "node_metadata.json"
70
+
71
+
72
+ def _encode_path_under_env(path: str, env_dir: Path) -> str:
73
+ candidate = Path(path)
74
+ if not candidate.is_absolute():
75
+ return path
76
+ try:
77
+ return str(candidate.absolute().relative_to(env_dir.absolute()))
78
+ except ValueError:
79
+ return path
80
+
81
+
82
+ def _decode_path_from_env(path: str, env_dir: Path) -> str:
83
+ candidate = Path(path)
84
+ if candidate.is_absolute():
85
+ return path
86
+ return str(env_dir / candidate)
87
+
88
+
89
+ def _existing_metadata_binaries(meta: dict) -> dict[str, str]:
90
+ binaries = meta.get("binaries", {})
91
+ if not isinstance(binaries, dict):
92
+ return {}
93
+ existing: dict[str, str] = {}
94
+ for name, bin_path in binaries.items():
95
+ path = Path(bin_path)
96
+ if path.exists():
97
+ existing[str(name)] = str(path)
98
+ return existing
99
+
100
+
101
+ def _write_metadata(
102
+ env_dir: Path,
103
+ *,
104
+ spec: str,
105
+ package_name: str,
106
+ version: str | None,
107
+ binaries: dict[str, str],
108
+ used_npm_fallback: bool = False,
109
+ runtime: str | None = None,
110
+ ) -> Path:
111
+ path = env_dir / _META_FILE
112
+ data = {
113
+ "spec": spec,
114
+ "package_name": package_name,
115
+ "version": version,
116
+ "binaries": {
117
+ name: _encode_path_under_env(path, env_dir)
118
+ for name, path in binaries.items()
119
+ },
120
+ "used_npm_fallback": used_npm_fallback,
121
+ "runtime": runtime,
122
+ }
123
+ path.parent.mkdir(parents=True, exist_ok=True)
124
+ with path.open("w", encoding="utf-8") as f:
125
+ json.dump(data, f, indent=2)
126
+ f.write("\n")
127
+ return path
128
+
129
+
130
+ def read_metadata(env_dir: Path) -> dict | None:
131
+ path = env_dir / _META_FILE
132
+ if not path.exists():
133
+ return None
134
+ with path.open(encoding="utf-8") as f:
135
+ data = json.load(f)
136
+ binaries = data.get("binaries")
137
+ if isinstance(binaries, dict):
138
+ data["binaries"] = {
139
+ name: _decode_path_from_env(path, env_dir)
140
+ for name, path in binaries.items()
141
+ }
142
+ return data
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # node_modules/.bin — ecosystem source of truth
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ def _dot_bin_dir(env_dir: Path) -> Path:
151
+ """Return ``node_modules/.bin/`` — populated by bun/npm with correct wrappers."""
152
+ return env_dir / "node_modules" / ".bin"
153
+
154
+
155
+ def _list_dot_bin(env_dir: Path) -> list[Path]:
156
+ """List executable entries in ``node_modules/.bin/``."""
157
+ dot_bin = _dot_bin_dir(env_dir)
158
+ if not dot_bin.exists():
159
+ return []
160
+ if is_windows():
161
+ # On Windows, bun/npm create .cmd wrappers
162
+ return sorted(
163
+ p for p in dot_bin.iterdir() if p.is_file() and p.suffix.lower() in {".cmd", ".exe"}
164
+ )
165
+ return sorted(
166
+ p
167
+ for p in dot_bin.iterdir()
168
+ if p.is_file() and os.access(p, os.X_OK) and not p.name.endswith(".cmd")
169
+ )
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Node shebang shim (bun-as-node when host has no node)
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ def _shim_node_shebangs(env_dir: Path, bun_path: str) -> list[str]:
178
+ """Rewrite ``#!...node`` shebangs to bun inside the tool's env.
179
+
180
+ Rationale: many npm packages (biome, prettier, …) ship a JS entrypoint
181
+ with ``#!/usr/bin/env node`` in their ``bin`` scripts. bun installs them
182
+ fine but the scripts fail at runtime if host has no node. bun itself is
183
+ a Node-compatible runtime, so rewriting the shebang to an absolute bun
184
+ path makes the tool self-contained — ixt's isolation stays intact.
185
+
186
+ Safety:
187
+ - Only acts when host has no ``node`` on PATH (no need to shim
188
+ otherwise, and users explicitly on node get their runtime).
189
+ - Breaks bun's global-cache hardlink before rewriting (unlink +
190
+ rewrite) so we never pollute ``~/.bun/install/cache``.
191
+
192
+ Returns the list of ``.bin/`` entry names whose real script was
193
+ rewritten (for a user-facing warning).
194
+ """
195
+ if shutil.which("node"):
196
+ return []
197
+
198
+ rewritten: list[str] = []
199
+ bin_dir = _dot_bin_dir(env_dir)
200
+ if not bin_dir.exists():
201
+ return rewritten
202
+
203
+ seen_targets: set[Path] = set()
204
+ for entry in bin_dir.iterdir():
205
+ if not entry.is_file():
206
+ continue
207
+ try:
208
+ target = entry.resolve()
209
+ except (OSError, RuntimeError):
210
+ continue
211
+ if target in seen_targets or not target.is_file():
212
+ continue
213
+ seen_targets.add(target)
214
+
215
+ try:
216
+ content = target.read_bytes()
217
+ except OSError:
218
+ continue
219
+
220
+ if not content.startswith(b"#!"):
221
+ continue
222
+ nl = content.find(b"\n")
223
+ if nl == -1:
224
+ continue
225
+ first_line = content[:nl]
226
+ if not _NODE_SHEBANG_RE.match(first_line):
227
+ continue
228
+
229
+ # Break potential hardlink to bun's global cache, then rewrite.
230
+ mode = target.stat().st_mode
231
+ new_content = f"#!{bun_path}".encode() + content[nl:]
232
+ target.unlink()
233
+ target.write_bytes(new_content)
234
+ target.chmod(mode)
235
+ rewritten.append(entry.name)
236
+
237
+ return rewritten
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Package introspection
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ def _read_package_json(env_dir: Path, package_name: str) -> dict | None:
246
+ """Read the installed package's package.json."""
247
+ pkg_json = env_dir / "node_modules" / package_name / "package.json"
248
+ if not pkg_json.exists():
249
+ return None
250
+ with pkg_json.open(encoding="utf-8") as f:
251
+ return json.load(f)
252
+
253
+
254
+ def _get_bin_entries(pkg_data: dict, package_name: str) -> dict[str, str]:
255
+ """Extract ``bin`` entries from package.json.
256
+
257
+ Returns ``{binary_name: relative_path}``.
258
+ """
259
+ raw = pkg_data.get("bin")
260
+ if raw is None:
261
+ return {}
262
+ if isinstance(raw, str):
263
+ # Single binary → use package short name
264
+ short = package_name.rsplit("/", 1)[-1]
265
+ return {short: raw}
266
+ if isinstance(raw, dict):
267
+ return dict(raw)
268
+ return {}
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # NodeBackend
273
+ # ---------------------------------------------------------------------------
274
+
275
+
276
+ @dataclass
277
+ class NodeBackend:
278
+ """Install Node CLI tools in isolated node_modules via ``bun``."""
279
+
280
+ settings: Settings = field(default_factory=get_settings)
281
+ backend_type: BackendType = field(default=BackendType.NODE, init=False)
282
+
283
+ # -- runtime helpers ----------------------------------------------------
284
+
285
+ def _bun(self) -> str:
286
+ from ixt.core.bootstrap import ensure_bun
287
+
288
+ return ensure_bun(self.settings)
289
+
290
+ def _npm_available(self) -> bool:
291
+ return command_exists("npm")
292
+
293
+ # -- Backend protocol ---------------------------------------------------
294
+
295
+ def env_exists(self, env_dir: Path) -> bool:
296
+ return (env_dir / "node_modules").is_dir()
297
+
298
+ def create_env(self, env_dir: Path) -> bool:
299
+ if env_dir.exists() and self.env_exists(env_dir):
300
+ return False
301
+ env_dir.mkdir(parents=True, exist_ok=True)
302
+ # Minimal package.json so bun/npm has a project root.
303
+ pkg_json = env_dir / "package.json"
304
+ if not pkg_json.exists():
305
+ pkg_json.write_text(
306
+ json.dumps({"private": True, "dependencies": {}}, indent=2) + "\n",
307
+ encoding="utf-8",
308
+ )
309
+ return True
310
+
311
+ def install_packages(
312
+ self,
313
+ env_dir: Path,
314
+ specs: list[str],
315
+ *,
316
+ upgrade: bool = False,
317
+ runtime: str | None = None,
318
+ node_shim: bool | None = None,
319
+ ) -> None:
320
+ if not specs:
321
+ return
322
+
323
+ main_spec = specs[0]
324
+ pkg_name, _version = parse_npm_spec(main_spec)
325
+
326
+ used_npm = self._run_install_command(env_dir, specs, runtime=runtime, upgrade=upgrade)
327
+ pkg_data, used_npm = self._verify_and_retry_with_npm(env_dir, pkg_name, specs, used_npm)
328
+
329
+ self._maybe_shim_node_shebangs(env_dir, used_npm=used_npm, node_shim=node_shim)
330
+
331
+ binaries = {p.stem: str(p) for p in _list_dot_bin(env_dir)}
332
+
333
+ _write_metadata(
334
+ env_dir,
335
+ spec=main_spec,
336
+ package_name=pkg_name,
337
+ version=pkg_data.get("version") if pkg_data else None,
338
+ binaries=binaries,
339
+ used_npm_fallback=used_npm,
340
+ runtime=runtime,
341
+ )
342
+
343
+ def _run_install_command(
344
+ self,
345
+ env_dir: Path,
346
+ specs: list[str],
347
+ *,
348
+ runtime: str | None,
349
+ upgrade: bool,
350
+ ) -> bool:
351
+ """Run the install via bun (default) or npm (explicit --runtime=node).
352
+
353
+ Returns True when npm was used (directly or as a bun-failure fallback).
354
+ """
355
+ log = get_logger("node")
356
+
357
+ if runtime == "node":
358
+ if not self._npm_available():
359
+ raise RuntimeError(
360
+ "Requested --runtime=node but npm is not available.\n"
361
+ " Hint: Install Node.js/npm: https://nodejs.org"
362
+ )
363
+ log.debug(f"npm install (--runtime=node): {' '.join(specs)}")
364
+ cmd = ["npm", "install", "--no-fund", "--no-audit", *specs]
365
+ shell_run(cmd, cwd=env_dir, capture_output=True, check=True)
366
+ return True
367
+
368
+ try:
369
+ bun = self._bun()
370
+ cmd = [bun, "add", *(["--force"] if upgrade else []), *specs]
371
+ log.debug(f"bun add: {' '.join(specs)}")
372
+ shell_run(cmd, cwd=env_dir, capture_output=True, check=True)
373
+ return False
374
+ except (ShellError, Exception) as exc:
375
+ if not self._npm_available():
376
+ raise RuntimeError(
377
+ f"bun install failed and npm is not available.\n"
378
+ f" bun error: {exc}\n"
379
+ f" Hint: Install Node.js/npm to enable fallback: https://nodejs.org"
380
+ ) from exc
381
+ log.debug(f"bun failed, falling back to npm: {exc}")
382
+ cmd = ["npm", "install", "--no-fund", "--no-audit", *specs]
383
+ shell_run(cmd, cwd=env_dir, capture_output=True, check=True)
384
+ return True
385
+
386
+ def _verify_and_retry_with_npm(
387
+ self,
388
+ env_dir: Path,
389
+ pkg_name: str,
390
+ specs: list[str],
391
+ used_npm: bool,
392
+ ) -> tuple[dict | None, bool]:
393
+ """Verify package.json is present; retry with npm if bun silently failed."""
394
+ pkg_data = _read_package_json(env_dir, pkg_name)
395
+ if pkg_data is None and not used_npm and self._npm_available():
396
+ get_logger("node").debug("Package not found after bun install, retrying with npm")
397
+ cmd = ["npm", "install", "--no-fund", "--no-audit", *specs]
398
+ shell_run(cmd, cwd=env_dir, capture_output=True, check=True)
399
+ return _read_package_json(env_dir, pkg_name), True
400
+ return pkg_data, used_npm
401
+
402
+ def _maybe_shim_node_shebangs(
403
+ self,
404
+ env_dir: Path,
405
+ *,
406
+ used_npm: bool,
407
+ node_shim: bool | None,
408
+ ) -> None:
409
+ """Rewrite #!node shebangs to bun when bun installed and host has no node.
410
+
411
+ Keeps the tool self-contained without requiring node on the host.
412
+ """
413
+ if used_npm or node_shim is False:
414
+ return
415
+ log = get_logger("node")
416
+ try:
417
+ bun_for_shim = self._bun()
418
+ rewritten = _shim_node_shebangs(env_dir, bun_for_shim)
419
+ except (OSError, ShellError) as exc:
420
+ log.debug(f"node shebang shim skipped: {exc}")
421
+ return
422
+ if rewritten:
423
+ log.warn(
424
+ "node not found on host — rewrote shebangs to bun for: "
425
+ f"{', '.join(rewritten)}. bun runs these as a "
426
+ "Node-compatible runtime; install Node.js on the host "
427
+ "if the tool misbehaves."
428
+ )
429
+
430
+ def uninstall_package(self, env_dir: Path, package: str) -> None:
431
+ """No-op: Node envs are atomic; full removal is handled by the caller."""
432
+
433
+ def find_binaries(self, env_dir: Path) -> list[Path]:
434
+ """Return all executables in ``node_modules/.bin/``."""
435
+ return _list_dot_bin(env_dir)
436
+
437
+ def get_package_binaries(self, env_dir: Path, package_name: str) -> dict[str, str]:
438
+ """Return binaries declared by *package_name* (not transitive deps).
439
+
440
+ Uses package.json ``bin`` field to filter ``node_modules/.bin/``
441
+ entries that belong to this specific package. Falls back to
442
+ metadata or full ``.bin/`` listing.
443
+ """
444
+ # Primary: match package.json "bin" entries against .bin/ contents
445
+ pkg_data = _read_package_json(env_dir, package_name)
446
+ if pkg_data is not None:
447
+ declared = _get_bin_entries(pkg_data, package_name)
448
+ if declared:
449
+ dot_bin = _dot_bin_dir(env_dir)
450
+ result: dict[str, str] = {}
451
+ for name in declared:
452
+ candidate = dot_bin / name
453
+ if is_windows():
454
+ candidate = dot_bin / f"{name}.cmd"
455
+ if candidate.exists():
456
+ result[name] = str(candidate)
457
+ if result:
458
+ return result
459
+
460
+ # Fallback: metadata (covers postinstall-generated binaries)
461
+ meta = read_metadata(env_dir)
462
+ if meta and meta.get("binaries"):
463
+ existing = _existing_metadata_binaries(meta)
464
+ if existing:
465
+ return existing
466
+
467
+ # Last resort: everything in .bin/
468
+ return {p.stem: str(p) for p in _list_dot_bin(env_dir)}
469
+
470
+ def installed_version(self, env_dir: Path, package_name: str) -> str | None:
471
+ # Fast path: metadata
472
+ meta = read_metadata(env_dir)
473
+ if meta and meta.get("version"):
474
+ return meta["version"]
475
+
476
+ # Slow path: read package.json
477
+ pkg_data = _read_package_json(env_dir, package_name)
478
+ if pkg_data is None:
479
+ return None
480
+ return pkg_data.get("version")
481
+
482
+ def export(self, env_dir: Path) -> list[str]:
483
+ meta = read_metadata(env_dir)
484
+ if meta is None:
485
+ return []
486
+ name = meta.get("package_name", "")
487
+ version = meta.get("version", "")
488
+ if name and version:
489
+ return [f"{name}@{version}"]
490
+ return []