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/__init__.py +6 -0
- dev_setup/__main__.py +9 -0
- dev_setup/base.py +74 -0
- dev_setup/catalog.py +188 -0
- dev_setup/cli.py +49 -0
- dev_setup/commands/__init__.py +0 -0
- dev_setup/commands/add_cmd.py +334 -0
- dev_setup/commands/catalog_cmd.py +37 -0
- dev_setup/commands/delete_cmd.py +36 -0
- dev_setup/commands/docs_cmd.py +34 -0
- dev_setup/commands/functions_cmd.py +87 -0
- dev_setup/commands/help_cmd.py +59 -0
- dev_setup/commands/install_cmd.py +129 -0
- dev_setup/commands/list_cmd.py +76 -0
- dev_setup/commands/remove_cmd.py +53 -0
- dev_setup/commands/run_cmd.py +66 -0
- dev_setup/commands/update_cmd.py +149 -0
- dev_setup/function_runner.py +133 -0
- dev_setup/functions.schema.json +106 -0
- dev_setup/functions.yaml +111 -0
- dev_setup/functions_catalog.py +217 -0
- dev_setup/functions_registry.py +149 -0
- dev_setup/generic.py +719 -0
- dev_setup/registry.py +97 -0
- dev_setup/tools.yaml +679 -0
- dev_setup/ui.py +111 -0
- devstuff-1.13.1.dist-info/METADATA +617 -0
- devstuff-1.13.1.dist-info/RECORD +30 -0
- devstuff-1.13.1.dist-info/WHEEL +4 -0
- devstuff-1.13.1.dist-info/entry_points.txt +3 -0
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
|