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/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 []
|