devstuff 1.13.1__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.
dev_setup/generic.py ADDED
@@ -0,0 +1,719 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import hashlib
5
+ import re
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, fields
11
+ from pathlib import Path
12
+
13
+ from dev_setup.base import Tool
14
+
15
+ _verbose: bool = False
16
+
17
+ # Auto-inferred requires per install type (re-derived on load, not persisted)
18
+ AUTO_REQUIRES = {
19
+ "npm": ["nvm"],
20
+ "pip": ["uv"],
21
+ "uvx": ["uv"],
22
+ }
23
+
24
+ # dataclass field name -> catalog YAML key (only where they differ)
25
+ _YAML_KEY = {"install_type": "type"}
26
+ # fields that are identity/metadata, always persisted
27
+ _ALWAYS_PERSIST = ("name", "description", "category", "install_type")
28
+ # fields never read from / written to the catalog
29
+ _NON_CATALOG = ("key", "builtin")
30
+
31
+
32
+ @dataclass
33
+ class UpdateStatus:
34
+ """Best-effort result of probing whether a newer version is available.
35
+
36
+ `available` is None when the install type has no reliable way to check
37
+ (script/bash) or the probe itself failed (offline, missing tool, etc).
38
+ """
39
+
40
+ current: str = ""
41
+ latest: str = ""
42
+ available: bool | None = None
43
+
44
+
45
+ def _run(cmd: list, *, cwd: Path | None = None) -> None:
46
+ """Run a command. Streams output when verbose, captures when not."""
47
+ if _verbose:
48
+ subprocess.run(cmd, check=True, cwd=cwd)
49
+ else:
50
+ try:
51
+ subprocess.run(cmd, check=True, capture_output=True, text=True, cwd=cwd)
52
+ except subprocess.CalledProcessError as e:
53
+ msg = e.stderr.strip() if e.stderr else f"exit code {e.returncode}"
54
+ raise RuntimeError(msg) from e
55
+
56
+
57
+ @dataclass
58
+ class GenericTool(Tool):
59
+ key: str = ""
60
+ name: str = ""
61
+ description: str = ""
62
+ category: str = "custom"
63
+ install_type: str = "unknown"
64
+ check_cmd: str = ""
65
+ version_cmd: str = ""
66
+ npm_name: str = ""
67
+ pip_name: str = ""
68
+ git_url: str = ""
69
+ git_install_cmd: str = ""
70
+ git_remove_cmd: str = ""
71
+ apt_packages: str = ""
72
+ script_url: str = ""
73
+ sha256: str = ""
74
+ install_script: str = ""
75
+ remove_script: str = ""
76
+ help_cmd: str = ""
77
+ docs_url: str = ""
78
+ requires: list | None = None
79
+ builtin: bool = False
80
+
81
+ def __post_init__(self) -> None:
82
+ if not self.name:
83
+ self.name = self.key
84
+ if self.requires is None:
85
+ self.requires = list(AUTO_REQUIRES.get(self.install_type, []))
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict, key: str) -> GenericTool:
89
+ kwargs = {
90
+ f.name: data.get(_YAML_KEY.get(f.name, f.name), f.default)
91
+ for f in fields(cls)
92
+ if f.name not in _NON_CATALOG and f.name != "requires"
93
+ }
94
+ # `requires` default is None (auto-derive); dict may carry an explicit list
95
+ kwargs["requires"] = data.get("requires")
96
+ return cls(key=key, **kwargs)
97
+
98
+ def to_dict(self) -> dict:
99
+ d: dict = {}
100
+ for f in fields(self):
101
+ if f.name in _NON_CATALOG or f.name == "requires":
102
+ continue
103
+ val = getattr(self, f.name)
104
+ if f.name in _ALWAYS_PERSIST or val:
105
+ d[_YAML_KEY.get(f.name, f.name)] = val
106
+ # Only persist explicit requires — auto-inferred ones are re-derived on load
107
+ if self.requires is not None and self.requires != AUTO_REQUIRES.get(self.install_type, []):
108
+ d["requires"] = self.requires
109
+ return d
110
+
111
+ def save(self) -> None:
112
+ from dev_setup import catalog
113
+ catalog.save_user_tool(self.key, self.to_dict())
114
+
115
+ # -- Strategy dispatch ----------------------------------------------------
116
+
117
+ def is_installed(self) -> bool:
118
+ if self.check_cmd:
119
+ return _check_cmd_installed(self.check_cmd, install_type=self.install_type)
120
+ checker = _CHECKERS.get(self.install_type)
121
+ return checker(self) if checker else False
122
+
123
+ def install(self) -> str | None:
124
+ installer = _INSTALLERS.get(self.install_type)
125
+ if installer is None:
126
+ raise RuntimeError(f"Unsupported install type: {self.install_type!r}")
127
+ installer(self)
128
+ return self.get_version() or None
129
+
130
+ def remove(self) -> None:
131
+ remover = _REMOVERS.get(self.install_type)
132
+ if remover is None:
133
+ raise RuntimeError(f"Unsupported remove type: {self.install_type!r}")
134
+ remover(self)
135
+
136
+ def update(self, version: str | None = None) -> str | None:
137
+ """Update an already-installed tool to the latest (or a specified) version."""
138
+ updater = _UPDATERS.get(self.install_type)
139
+ if updater is None:
140
+ raise RuntimeError(f"Unsupported update type: {self.install_type!r}")
141
+ updater(self, version)
142
+ return self.get_version() or None
143
+
144
+ def check_for_update(self) -> UpdateStatus:
145
+ """Best-effort probe for a newer version. Never raises."""
146
+ checker = _UPDATE_CHECKERS.get(self.install_type)
147
+ if checker is None:
148
+ return UpdateStatus()
149
+ try:
150
+ return checker(self)
151
+ except Exception:
152
+ return UpdateStatus()
153
+
154
+ def get_version(self) -> str:
155
+ # Prefer explicit version_cmd; fall through to check_cmd / type-derived cmd
156
+ cmd = self.version_cmd or (
157
+ self.check_cmd if _is_simple_command(self.check_cmd or "") else ""
158
+ ) or _type_cmd(self)
159
+
160
+ if cmd and shutil.which(cmd):
161
+ for flag in ["--version", "-v", "version"]:
162
+ try:
163
+ r = subprocess.run([cmd, flag], capture_output=True, text=True, timeout=5)
164
+ if r.returncode == 0 and r.stdout.strip():
165
+ return r.stdout.strip().splitlines()[0]
166
+ except Exception:
167
+ pass
168
+ return "installed"
169
+
170
+ # Complex check_cmd (shell expression) — probe via login shell using tool key
171
+ if self.check_cmd and not _is_simple_command(self.check_cmd):
172
+ return _bash_version(self.key)
173
+
174
+ return ""
175
+
176
+
177
+ # -- Install strategies --------------------------------------------------------
178
+
179
+
180
+ def _install_npm(tool: GenericTool) -> None:
181
+ from dev_setup import ui
182
+ if not tool.npm_name:
183
+ raise RuntimeError("npm_name not set")
184
+ with ui.spinner(f"Installing {tool.name} via npm..."):
185
+ subprocess.run(
186
+ ["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(tool.npm_name)}"],
187
+ check=True, capture_output=True,
188
+ )
189
+
190
+
191
+ def _install_uvx(tool: GenericTool) -> None:
192
+ from dev_setup import ui
193
+ if not tool.pip_name:
194
+ raise RuntimeError("pip_name not set")
195
+ uv = shutil.which("uv")
196
+ if not uv:
197
+ raise RuntimeError(
198
+ "uv is required to install uvx packages. "
199
+ "Install it first: devstuff install uv"
200
+ )
201
+ with ui.spinner(f"Installing {tool.name} via uvx..."):
202
+ subprocess.run([uv, "tool", "install", tool.pip_name], check=True, capture_output=True)
203
+
204
+
205
+ def _install_git(tool: GenericTool) -> None:
206
+ from dev_setup import ui
207
+ if not tool.git_url:
208
+ raise RuntimeError("git_url not set")
209
+ dest = _git_clone_dest(tool.git_url)
210
+ with ui.spinner(f"Cloning {tool.name}..."):
211
+ subprocess.run(
212
+ ["git", "clone", "--depth=1", tool.git_url, str(dest)],
213
+ check=True, capture_output=True,
214
+ )
215
+ if tool.git_install_cmd:
216
+ with ui.spinner("Running install command..."):
217
+ subprocess.run(
218
+ ["bash", "-c", tool.git_install_cmd],
219
+ cwd=dest, check=True, capture_output=True,
220
+ )
221
+
222
+
223
+ def _install_apt(tool: GenericTool) -> None:
224
+ from dev_setup import ui
225
+ if not tool.apt_packages:
226
+ raise RuntimeError("apt_packages not set")
227
+ ui.info(f"Installing {tool.name} via apt...")
228
+ subprocess.run(["sudo", "apt-get", "update", "-q"], capture_output=True)
229
+ _run(["sudo", "apt-get", "install", "-y"] + tool.apt_packages.split())
230
+
231
+
232
+ def _install_script_url(tool: GenericTool) -> None:
233
+ from dev_setup import ui
234
+ if not tool.script_url:
235
+ raise RuntimeError("script_url not set")
236
+ ui.info(f"Running install script for {tool.name}...")
237
+ script = _download_script(tool.script_url, expected_sha256=tool.sha256)
238
+ _run_bash_script(script)
239
+
240
+
241
+ def _install_bash(tool: GenericTool) -> None:
242
+ from dev_setup import ui
243
+ if not tool.install_script:
244
+ raise RuntimeError("install_script not set")
245
+ ui.info(f"Installing {tool.name}...")
246
+ _run_bash_script(tool.install_script)
247
+
248
+
249
+ # -- Update strategies --------------------------------------------------------
250
+
251
+
252
+ def _update_npm(tool: GenericTool, version: str | None) -> None:
253
+ from dev_setup import ui
254
+ if not tool.npm_name:
255
+ raise RuntimeError("npm_name not set")
256
+ target = f"{tool.npm_name}@{version or 'latest'}"
257
+ with ui.spinner(f"Updating {tool.name} via npm..."):
258
+ subprocess.run(
259
+ ["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(target)}"],
260
+ check=True, capture_output=True,
261
+ )
262
+
263
+
264
+ def _update_uvx(tool: GenericTool, version: str | None) -> None:
265
+ from dev_setup import ui
266
+ if not tool.pip_name:
267
+ raise RuntimeError("pip_name not set")
268
+ uv = shutil.which("uv")
269
+ if not uv:
270
+ raise RuntimeError(
271
+ "uv is required to update uvx packages. "
272
+ "Install it first: devstuff install uv"
273
+ )
274
+ target = f"{tool.pip_name}=={version}" if version else tool.pip_name
275
+ with ui.spinner(f"Updating {tool.name} via uv tool upgrade..."):
276
+ subprocess.run([uv, "tool", "upgrade", target], check=True, capture_output=True)
277
+
278
+
279
+ def _update_apt(tool: GenericTool, version: str | None) -> None:
280
+ from dev_setup import ui
281
+ if not tool.apt_packages:
282
+ raise RuntimeError("apt_packages not set")
283
+ packages = tool.apt_packages.split()
284
+ subprocess.run(["sudo", "apt-get", "update", "-q"], capture_output=True)
285
+ if version:
286
+ if len(packages) != 1:
287
+ raise RuntimeError(
288
+ "Version pinning is only supported for apt tools with a single package."
289
+ )
290
+ ui.info(f"Updating {tool.name} to version {version} via apt...")
291
+ _run(["sudo", "apt-get", "install", "-y", f"{packages[0]}={version}"])
292
+ else:
293
+ ui.info(f"Updating {tool.name} via apt...")
294
+ _run(["sudo", "apt-get", "install", "--only-upgrade", "-y"] + packages)
295
+
296
+
297
+ def _update_git(tool: GenericTool, version: str | None) -> None:
298
+ from dev_setup import ui
299
+ if version:
300
+ raise RuntimeError(
301
+ "Version pinning is not supported for git tools (shallow clone). "
302
+ f"Reinstall instead: devstuff remove {tool.key} && devstuff install {tool.key}"
303
+ )
304
+ if not tool.git_url:
305
+ raise RuntimeError("git_url not set")
306
+ dest = _git_clone_dest(tool.git_url)
307
+ if not dest.exists():
308
+ raise RuntimeError(f"{tool.name} clone not found at {dest}")
309
+ with ui.spinner(f"Pulling latest {tool.name}..."):
310
+ _run(["git", "-C", str(dest), "pull"])
311
+ if tool.git_install_cmd:
312
+ subprocess.run(
313
+ ["bash", "-c", tool.git_install_cmd],
314
+ cwd=dest, check=True, capture_output=True,
315
+ )
316
+
317
+
318
+ def _update_script_url(tool: GenericTool, version: str | None) -> None:
319
+ if version:
320
+ raise RuntimeError("Version pinning is not supported for 'script' tools.")
321
+ _install_script_url(tool)
322
+
323
+
324
+ def _update_bash(tool: GenericTool, version: str | None) -> None:
325
+ if version:
326
+ raise RuntimeError("Version pinning is not supported for 'bash' tools.")
327
+ _install_bash(tool)
328
+
329
+
330
+ # -- Update-availability checks -------------------------------------------------
331
+ # Best-effort: each function returns UpdateStatus() (all-empty/unknown) rather
332
+ # than raising, so a probe failure just shows as "unknown" to the caller.
333
+
334
+
335
+ def _npm_installed_version(pkg: str) -> str:
336
+ import json
337
+ try:
338
+ r = subprocess.run(
339
+ ["bash", "-lc", f"{_npm_init()} && npm list -g --depth=0 --json {shlex.quote(pkg)}"],
340
+ capture_output=True, text=True, timeout=10,
341
+ )
342
+ data = json.loads(r.stdout or "{}")
343
+ return data.get("dependencies", {}).get(pkg, {}).get("version", "")
344
+ except Exception:
345
+ return ""
346
+
347
+
348
+ def _npm_latest_version(pkg: str) -> str:
349
+ try:
350
+ r = subprocess.run(
351
+ ["bash", "-lc", f"{_npm_init()} && npm view {shlex.quote(pkg)} version"],
352
+ capture_output=True, text=True, timeout=10,
353
+ )
354
+ return r.stdout.strip() if r.returncode == 0 else ""
355
+ except Exception:
356
+ return ""
357
+
358
+
359
+ def _check_update_npm(tool: GenericTool) -> UpdateStatus:
360
+ if not tool.npm_name:
361
+ return UpdateStatus()
362
+ current = _npm_installed_version(tool.npm_name)
363
+ latest = _npm_latest_version(tool.npm_name)
364
+ if not latest:
365
+ return UpdateStatus(current=current)
366
+ return UpdateStatus(current=current, latest=latest, available=bool(current) and current != latest)
367
+
368
+
369
+ def _uv_tool_current_version(pkg: str) -> str:
370
+ uv = shutil.which("uv")
371
+ if not uv:
372
+ return ""
373
+ try:
374
+ r = subprocess.run(
375
+ [uv, "tool", "list", "--color", "never"], capture_output=True, text=True, timeout=15,
376
+ )
377
+ except Exception:
378
+ return ""
379
+ for line in r.stdout.splitlines():
380
+ if line.startswith((" ", "-", "\t")):
381
+ continue # sub-lines (installed executables) under each tool
382
+ m = re.match(rf"^{re.escape(pkg)}\s+v?([\w.\-+]+)", line.strip())
383
+ if m:
384
+ return m.group(1)
385
+ return ""
386
+
387
+
388
+ @functools.lru_cache(maxsize=1)
389
+ def _uv_outdated_map() -> dict[str, str]:
390
+ """Package name -> latest version, for every outdated `uv tool`. One call per process."""
391
+ uv = shutil.which("uv")
392
+ if not uv:
393
+ return {}
394
+ try:
395
+ r = subprocess.run(
396
+ [uv, "tool", "list", "--outdated", "--color", "never"],
397
+ capture_output=True, text=True, timeout=20,
398
+ )
399
+ except Exception:
400
+ return {}
401
+ result: dict[str, str] = {}
402
+ for line in r.stdout.splitlines():
403
+ if line.startswith((" ", "-", "\t")):
404
+ continue
405
+ m = re.match(r"^(\S+)\s+v?[\w.\-+]+\s*\[latest:\s*([\w.\-+]+)\]", line.strip())
406
+ if m:
407
+ result[m.group(1)] = m.group(2)
408
+ return result
409
+
410
+
411
+ def _check_update_uvx(tool: GenericTool) -> UpdateStatus:
412
+ if not tool.pip_name or not shutil.which("uv"):
413
+ return UpdateStatus()
414
+ current = _uv_tool_current_version(tool.pip_name)
415
+ latest = _uv_outdated_map().get(tool.pip_name, "")
416
+ if not latest:
417
+ return UpdateStatus(current=current, available=False if current else None)
418
+ return UpdateStatus(current=current, latest=latest, available=True)
419
+
420
+
421
+ def _check_update_apt(tool: GenericTool) -> UpdateStatus:
422
+ if not tool.apt_packages:
423
+ return UpdateStatus()
424
+ pkg = tool.apt_packages.split()[0]
425
+ try:
426
+ r = subprocess.run(
427
+ ["dpkg-query", "-W", "-f=${Version}", pkg], capture_output=True, text=True, timeout=10,
428
+ )
429
+ current = r.stdout.strip() if r.returncode == 0 else ""
430
+ except Exception:
431
+ current = ""
432
+ try:
433
+ r = subprocess.run(["apt-cache", "policy", pkg], capture_output=True, text=True, timeout=10)
434
+ except Exception:
435
+ return UpdateStatus(current=current)
436
+ candidate = ""
437
+ for line in r.stdout.splitlines():
438
+ line = line.strip()
439
+ if line.startswith("Candidate:"):
440
+ candidate = line.split(":", 1)[1].strip()
441
+ break
442
+ if not candidate or candidate == "(none)":
443
+ return UpdateStatus(current=current)
444
+ return UpdateStatus(current=current, latest=candidate, available=bool(current) and current != candidate)
445
+
446
+
447
+ def _check_update_git(tool: GenericTool) -> UpdateStatus:
448
+ if not tool.git_url:
449
+ return UpdateStatus()
450
+ dest = _git_clone_dest(tool.git_url)
451
+ if not dest.exists():
452
+ return UpdateStatus()
453
+ try:
454
+ r = subprocess.run(
455
+ ["git", "-C", str(dest), "rev-parse", "--short", "HEAD"],
456
+ capture_output=True, text=True, timeout=10,
457
+ )
458
+ current = r.stdout.strip() if r.returncode == 0 else ""
459
+ except Exception:
460
+ current = ""
461
+ try:
462
+ r = subprocess.run(
463
+ ["git", "ls-remote", tool.git_url, "HEAD"], capture_output=True, text=True, timeout=10,
464
+ )
465
+ remote_full = r.stdout.split()[0] if r.returncode == 0 and r.stdout.strip() else ""
466
+ except Exception:
467
+ remote_full = ""
468
+ if not remote_full:
469
+ return UpdateStatus(current=current)
470
+ latest = remote_full[:7]
471
+ if not current:
472
+ return UpdateStatus(latest=latest)
473
+ return UpdateStatus(current=current, latest=latest, available=not remote_full.startswith(current))
474
+
475
+
476
+ _UPDATE_CHECKERS: dict[str, Callable[[GenericTool], UpdateStatus]] = {
477
+ "npm": _check_update_npm,
478
+ "pip": _check_update_uvx,
479
+ "uvx": _check_update_uvx,
480
+ "apt": _check_update_apt,
481
+ "git": _check_update_git,
482
+ }
483
+
484
+
485
+ # -- Remove strategies -----------------------------------------------------------
486
+
487
+
488
+ def _remove_npm(tool: GenericTool) -> None:
489
+ from dev_setup import ui
490
+ with ui.spinner(f"Removing {tool.name}..."):
491
+ subprocess.run(
492
+ ["bash", "-lc", f"{_npm_init()} && npm uninstall -g {shlex.quote(tool.npm_name)}"],
493
+ check=True, capture_output=True,
494
+ )
495
+
496
+
497
+ def _remove_uvx(tool: GenericTool) -> None:
498
+ from dev_setup import ui
499
+ uv = shutil.which("uv")
500
+ if not uv:
501
+ raise RuntimeError(
502
+ "uv is required to remove uvx packages. "
503
+ "Install it first: devstuff install uv"
504
+ )
505
+ with ui.spinner(f"Removing {tool.name}..."):
506
+ subprocess.run([uv, "tool", "uninstall", tool.pip_name], check=True, capture_output=True)
507
+
508
+
509
+ def _remove_git(tool: GenericTool) -> None:
510
+ from dev_setup import ui
511
+ dest = _git_clone_dest(tool.git_url)
512
+ if tool.git_remove_cmd:
513
+ with ui.spinner("Running remove command..."):
514
+ subprocess.run(["bash", "-c", tool.git_remove_cmd], cwd=dest, capture_output=True)
515
+ if dest.exists():
516
+ shutil.rmtree(dest)
517
+
518
+
519
+ def _remove_apt(tool: GenericTool) -> None:
520
+ from dev_setup import ui
521
+ ui.info(f"Removing {tool.name}...")
522
+ if tool.remove_script:
523
+ _run_bash_script(tool.remove_script)
524
+ else:
525
+ _run(["sudo", "apt-get", "remove", "-y"] + tool.apt_packages.split())
526
+
527
+
528
+ def _remove_script_url(tool: GenericTool) -> None:
529
+ from dev_setup import ui
530
+ if not tool.remove_script:
531
+ raise RuntimeError(
532
+ "No remove script defined for this script-installed package. "
533
+ "Remove manually then run: devstuff delete " + tool.key
534
+ )
535
+ ui.info(f"Removing {tool.name}...")
536
+ _run_bash_script(tool.remove_script)
537
+
538
+
539
+ def _remove_bash(tool: GenericTool) -> None:
540
+ from dev_setup import ui
541
+ if not tool.remove_script:
542
+ raise RuntimeError(
543
+ f"No remove script defined for '{tool.key}'. "
544
+ "Remove manually then run: devstuff delete " + tool.key
545
+ )
546
+ ui.info(f"Removing {tool.name}...")
547
+ _run_bash_script(tool.remove_script)
548
+
549
+
550
+ # -- Installed-state strategies ---------------------------------------------------
551
+
552
+
553
+ def _installed_npm(tool: GenericTool) -> bool:
554
+ return bool(tool.npm_name) and _npm_global_installed(tool.npm_name)
555
+
556
+
557
+ def _installed_uvx(tool: GenericTool) -> bool:
558
+ return bool(tool.pip_name) and shutil.which(tool.pip_name) is not None
559
+
560
+
561
+ def _installed_git(tool: GenericTool) -> bool:
562
+ return bool(tool.git_url) and _git_clone_dest(tool.git_url).exists()
563
+
564
+
565
+ def _installed_apt(tool: GenericTool) -> bool:
566
+ return bool(tool.apt_packages) and _apt_installed(tool.apt_packages.split()[0])
567
+
568
+
569
+ _INSTALLERS: dict[str, Callable[[GenericTool], None]] = {
570
+ "npm": _install_npm,
571
+ "pip": _install_uvx,
572
+ "uvx": _install_uvx,
573
+ "git": _install_git,
574
+ "apt": _install_apt,
575
+ "script": _install_script_url,
576
+ "bash": _install_bash,
577
+ }
578
+
579
+ _REMOVERS: dict[str, Callable[[GenericTool], None]] = {
580
+ "npm": _remove_npm,
581
+ "pip": _remove_uvx,
582
+ "uvx": _remove_uvx,
583
+ "git": _remove_git,
584
+ "apt": _remove_apt,
585
+ "script": _remove_script_url,
586
+ "bash": _remove_bash,
587
+ }
588
+
589
+ _CHECKERS: dict[str, Callable[[GenericTool], bool]] = {
590
+ "npm": _installed_npm,
591
+ "pip": _installed_uvx,
592
+ "uvx": _installed_uvx,
593
+ "git": _installed_git,
594
+ "apt": _installed_apt,
595
+ }
596
+
597
+ _UPDATERS: dict[str, Callable[[GenericTool, str | None], None]] = {
598
+ "npm": _update_npm,
599
+ "pip": _update_uvx,
600
+ "uvx": _update_uvx,
601
+ "git": _update_git,
602
+ "apt": _update_apt,
603
+ "script": _update_script_url,
604
+ "bash": _update_bash,
605
+ }
606
+
607
+
608
+ # -- Helpers ----------------------------------------------------------------------
609
+
610
+
611
+ def _npm_global_installed(pkg: str) -> bool:
612
+ try:
613
+ r = subprocess.run(
614
+ ["bash", "-lc", f"{_npm_init()} && npm list -g --depth=0 {shlex.quote(pkg)}"],
615
+ capture_output=True,
616
+ text=True,
617
+ )
618
+ return pkg in r.stdout
619
+ except Exception:
620
+ return False
621
+
622
+
623
+ def _npm_init() -> str:
624
+ return '. "$HOME/.nvm/nvm.sh" 2>/dev/null || true'
625
+
626
+
627
+ def _apt_installed(pkg: str) -> bool:
628
+ try:
629
+ r = subprocess.run(["dpkg", "-s", pkg], capture_output=True, text=True)
630
+ return "Status: install ok installed" in r.stdout
631
+ except Exception:
632
+ return False
633
+
634
+
635
+ def _git_clone_dest(url: str) -> Path:
636
+ repo_name = url.rstrip("/").split("/")[-1].removesuffix(".git")
637
+ return Path.home() / ".local" / "share" / "dev-setup" / repo_name
638
+
639
+
640
+ def _download_script(url: str, *, expected_sha256: str = "") -> str:
641
+ """Download a script over HTTPS and optionally verify its sha256."""
642
+ import urllib.request
643
+
644
+ with urllib.request.urlopen(url, timeout=30) as resp:
645
+ data = resp.read()
646
+
647
+ if expected_sha256:
648
+ actual = hashlib.sha256(data).hexdigest()
649
+ if actual != expected_sha256.lower():
650
+ raise RuntimeError(
651
+ f"Checksum mismatch for {url}\n"
652
+ f" expected: {expected_sha256.lower()}\n"
653
+ f" actual: {actual}\n"
654
+ "The script may have changed upstream — refusing to run it."
655
+ )
656
+
657
+ return data.decode("utf-8")
658
+
659
+
660
+ def _run_bash_script(script: str) -> None:
661
+ import os
662
+ import tempfile
663
+
664
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
665
+ f.write(script)
666
+ tmp = f.name
667
+ try:
668
+ _run(["bash", tmp])
669
+ finally:
670
+ os.unlink(tmp)
671
+
672
+
673
+ def _type_cmd(tool: GenericTool) -> str:
674
+ t = tool.install_type
675
+ if t == "npm":
676
+ return tool.npm_name
677
+ if t in ("pip", "uvx"):
678
+ return tool.pip_name
679
+ return ""
680
+
681
+
682
+ def _is_simple_command(cmd: str) -> bool:
683
+ return bool(cmd) and all(c not in cmd for c in " \t\n;&|$`'\"()<>")
684
+
685
+
686
+ def _bash_version(key: str) -> str:
687
+ for flag in ["--version", "-v", "version"]:
688
+ try:
689
+ r = subprocess.run(
690
+ [
691
+ "bash", "-lc",
692
+ f'. "$HOME/.nvm/nvm.sh" 2>/dev/null; {shlex.quote(key)} {flag} 2>/dev/null',
693
+ ],
694
+ capture_output=True, text=True, timeout=5,
695
+ )
696
+ if r.returncode == 0 and r.stdout.strip():
697
+ return r.stdout.strip().splitlines()[0]
698
+ except Exception:
699
+ pass
700
+ return ""
701
+
702
+
703
+ def _check_cmd_installed(cmd: str, *, install_type: str = "") -> bool:
704
+ if _is_simple_command(cmd):
705
+ if shutil.which(cmd) is not None:
706
+ return True
707
+ # Only source nvm for npm-type tools — avoids unnecessary shell overhead
708
+ prefix = f"{_npm_init()} && " if install_type == "npm" else ""
709
+ try:
710
+ return subprocess.run(
711
+ ["bash", "-lc", f"{prefix}command -v {cmd} >/dev/null 2>&1"],
712
+ capture_output=True,
713
+ ).returncode == 0
714
+ except Exception:
715
+ return False
716
+ try:
717
+ return subprocess.run(["bash", "-lc", cmd], capture_output=True).returncode == 0
718
+ except Exception:
719
+ return False