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/__init__.py
ADDED
dev_setup/__main__.py
ADDED
dev_setup/base.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Tool(ABC):
|
|
8
|
+
key: str = ""
|
|
9
|
+
name: str = ""
|
|
10
|
+
description: str = ""
|
|
11
|
+
category: str = "custom"
|
|
12
|
+
install_type: str = "unknown"
|
|
13
|
+
builtin: bool = True
|
|
14
|
+
help_cmd: str = ""
|
|
15
|
+
docs_url: str = ""
|
|
16
|
+
requires: list = [] # keys of tools that must be installed before this one
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def is_installed(self) -> bool: ...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def install(self) -> str | None:
|
|
23
|
+
"""Install the tool. Return version string if available. Raise on failure."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def remove(self) -> None:
|
|
28
|
+
"""Uninstall the tool. Raise on failure."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def get_version(self) -> str:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def patch_bashrc(block_name: str, content: str) -> bool:
|
|
36
|
+
"""Idempotently append a named block to ~/.bashrc. Returns True if added."""
|
|
37
|
+
bashrc = Path.home() / ".bashrc"
|
|
38
|
+
marker = f"# {block_name}"
|
|
39
|
+
|
|
40
|
+
if bashrc.exists() and marker in bashrc.read_text():
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
with bashrc.open("a") as f:
|
|
44
|
+
f.write(f"\n{marker}\n{content}\n")
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def remove_bashrc_block(block_name: str) -> bool:
|
|
49
|
+
"""Remove a named block (and the line after the marker) from ~/.bashrc."""
|
|
50
|
+
bashrc = Path.home() / ".bashrc"
|
|
51
|
+
if not bashrc.exists():
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
lines = bashrc.read_text().splitlines(keepends=True)
|
|
55
|
+
marker = f"# {block_name}"
|
|
56
|
+
out = []
|
|
57
|
+
i = 0
|
|
58
|
+
removed = False
|
|
59
|
+
while i < len(lines):
|
|
60
|
+
stripped = lines[i].rstrip()
|
|
61
|
+
if stripped == marker:
|
|
62
|
+
i += 1
|
|
63
|
+
while i < len(lines) and lines[i].strip():
|
|
64
|
+
i += 1
|
|
65
|
+
if i < len(lines) and not lines[i].strip():
|
|
66
|
+
i += 1
|
|
67
|
+
removed = True
|
|
68
|
+
else:
|
|
69
|
+
out.append(lines[i])
|
|
70
|
+
i += 1
|
|
71
|
+
|
|
72
|
+
if removed:
|
|
73
|
+
bashrc.write_text("".join(out))
|
|
74
|
+
return removed
|
dev_setup/catalog.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import re
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path.home() / ".config" / "dev-setup"
|
|
12
|
+
USER_CATALOG_PATH = CONFIG_DIR / "tools.yaml"
|
|
13
|
+
BUNDLED_CATALOG = "tools.yaml"
|
|
14
|
+
|
|
15
|
+
VERSION = 1
|
|
16
|
+
VALID_KEY = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
17
|
+
SUPPORTED_FIELDS = {
|
|
18
|
+
"name",
|
|
19
|
+
"description",
|
|
20
|
+
"category",
|
|
21
|
+
"type",
|
|
22
|
+
"check_cmd",
|
|
23
|
+
"version_cmd",
|
|
24
|
+
"help_cmd",
|
|
25
|
+
"docs_url",
|
|
26
|
+
"requires",
|
|
27
|
+
"npm_name",
|
|
28
|
+
"pip_name",
|
|
29
|
+
"apt_packages",
|
|
30
|
+
"git_url",
|
|
31
|
+
"git_install_cmd",
|
|
32
|
+
"git_remove_cmd",
|
|
33
|
+
"script_url",
|
|
34
|
+
"sha256",
|
|
35
|
+
"install_script",
|
|
36
|
+
"remove_script",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CatalogError(RuntimeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bundled_catalog_path() -> str:
|
|
45
|
+
return f"dev_setup/{BUNDLED_CATALOG}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_catalog_file(path: Path, *, required: bool = False) -> dict[str, dict[str, Any]]:
|
|
49
|
+
if not path.exists():
|
|
50
|
+
if required:
|
|
51
|
+
raise CatalogError(f"Catalog file not found: {path}")
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
56
|
+
except yaml.YAMLError as exc:
|
|
57
|
+
raise CatalogError(f"Invalid YAML in {path}: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
return validate_catalog(raw, source=path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_catalog(raw: Any, *, source: Path | str = "<catalog>") -> dict[str, dict[str, Any]]:
|
|
63
|
+
if not isinstance(raw, dict):
|
|
64
|
+
raise CatalogError(f"{source}: catalog must be a mapping")
|
|
65
|
+
if raw.get("version") != VERSION:
|
|
66
|
+
raise CatalogError(f"{source}: version must be {VERSION}")
|
|
67
|
+
|
|
68
|
+
tools = raw.get("tools")
|
|
69
|
+
if tools is None:
|
|
70
|
+
return {}
|
|
71
|
+
if not isinstance(tools, dict):
|
|
72
|
+
raise CatalogError(f"{source}: tools must be a mapping")
|
|
73
|
+
|
|
74
|
+
validated: dict[str, dict[str, Any]] = {}
|
|
75
|
+
for key, data in tools.items():
|
|
76
|
+
if not isinstance(key, str) or not VALID_KEY.match(key):
|
|
77
|
+
raise CatalogError(f"{source}: invalid tool key {key!r}")
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
raise CatalogError(f"{source}: tool {key!r} must be a mapping")
|
|
80
|
+
|
|
81
|
+
unknown = sorted(set(data) - SUPPORTED_FIELDS)
|
|
82
|
+
if unknown:
|
|
83
|
+
fields = ", ".join(unknown)
|
|
84
|
+
raise CatalogError(f"{source}: tool {key!r} has unknown field(s): {fields}")
|
|
85
|
+
|
|
86
|
+
item = copy.deepcopy(data)
|
|
87
|
+
item.setdefault("name", key)
|
|
88
|
+
item.setdefault("description", "")
|
|
89
|
+
item.setdefault("category", "custom")
|
|
90
|
+
item.setdefault("type", "unknown")
|
|
91
|
+
|
|
92
|
+
requires = item.get("requires")
|
|
93
|
+
if requires is None:
|
|
94
|
+
if item["type"] == "npm":
|
|
95
|
+
item["requires"] = ["nvm"]
|
|
96
|
+
elif item["type"] in ("pip", "uvx"):
|
|
97
|
+
item["requires"] = ["uv"]
|
|
98
|
+
else:
|
|
99
|
+
item["requires"] = []
|
|
100
|
+
elif not isinstance(requires, list) or not all(isinstance(v, str) for v in requires):
|
|
101
|
+
raise CatalogError(f"{source}: tool {key!r} requires must be a list of strings")
|
|
102
|
+
|
|
103
|
+
validated[key] = item
|
|
104
|
+
|
|
105
|
+
return validated
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def catalog_document(tools: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
|
109
|
+
return {"version": VERSION, "tools": tools}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_user_catalog() -> dict[str, dict[str, Any]]:
|
|
113
|
+
return load_catalog_file(USER_CATALOG_PATH)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_user_catalog(tools: dict[str, dict[str, Any]]) -> None:
|
|
117
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
USER_CATALOG_PATH.write_text(_dump(catalog_document(tools)))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_bundled_catalog() -> dict[str, dict[str, Any]]:
|
|
122
|
+
resource = resources.files("dev_setup").joinpath(BUNDLED_CATALOG)
|
|
123
|
+
try:
|
|
124
|
+
raw = yaml.safe_load(resource.read_text()) or {}
|
|
125
|
+
except FileNotFoundError as exc:
|
|
126
|
+
raise CatalogError(f"Catalog file not found: {bundled_catalog_path()}") from exc
|
|
127
|
+
except yaml.YAMLError as exc:
|
|
128
|
+
raise CatalogError(f"Invalid YAML in {bundled_catalog_path()}: {exc}") from exc
|
|
129
|
+
return validate_catalog(raw, source=bundled_catalog_path())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_effective_catalog() -> tuple[
|
|
133
|
+
dict[str, dict[str, Any]],
|
|
134
|
+
dict[str, dict[str, Any]],
|
|
135
|
+
dict[str, dict[str, Any]],
|
|
136
|
+
]:
|
|
137
|
+
bundled = load_bundled_catalog()
|
|
138
|
+
user = read_user_catalog()
|
|
139
|
+
effective = merge_catalogs(bundled, user)
|
|
140
|
+
return effective, bundled, user
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def merge_catalogs(
|
|
144
|
+
bundled: dict[str, dict[str, Any]],
|
|
145
|
+
user: dict[str, dict[str, Any]],
|
|
146
|
+
) -> dict[str, dict[str, Any]]:
|
|
147
|
+
merged = copy.deepcopy(bundled)
|
|
148
|
+
for key, data in user.items():
|
|
149
|
+
merged[key] = copy.deepcopy(data)
|
|
150
|
+
return merged
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_user_tool(key: str, data: dict[str, Any]) -> None:
|
|
154
|
+
validate_catalog(catalog_document({key: data}), source=USER_CATALOG_PATH)
|
|
155
|
+
user = read_user_catalog()
|
|
156
|
+
user[key] = copy.deepcopy(data)
|
|
157
|
+
write_user_catalog(user)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def delete_user_tool(key: str) -> bool:
|
|
161
|
+
user = read_user_catalog()
|
|
162
|
+
if key not in user:
|
|
163
|
+
return False
|
|
164
|
+
del user[key]
|
|
165
|
+
write_user_catalog(user)
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def user_has_tool(key: str) -> bool:
|
|
170
|
+
return key in read_user_catalog()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def import_catalog(path: Path) -> list[str]:
|
|
174
|
+
incoming = load_catalog_file(path, required=True)
|
|
175
|
+
user = read_user_catalog()
|
|
176
|
+
for key, data in incoming.items():
|
|
177
|
+
user[key] = copy.deepcopy(data)
|
|
178
|
+
write_user_catalog(user)
|
|
179
|
+
return list(incoming)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def export_catalog(path: Path) -> None:
|
|
183
|
+
effective, _bundled, _user = load_effective_catalog()
|
|
184
|
+
path.write_text(_dump(catalog_document(effective)))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _dump(data: dict[str, Any]) -> str:
|
|
188
|
+
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
dev_setup/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from dev_setup import __version__
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.group(
|
|
7
|
+
invoke_without_command=True,
|
|
8
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
9
|
+
)
|
|
10
|
+
@click.pass_context
|
|
11
|
+
def cli(ctx: click.Context) -> None:
|
|
12
|
+
if ctx.invoked_subcommand is None:
|
|
13
|
+
from dev_setup.commands.help_cmd import print_help
|
|
14
|
+
print_help()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cli.command("version")
|
|
18
|
+
def version_cmd() -> None:
|
|
19
|
+
"""Print version and exit."""
|
|
20
|
+
click.echo(f"devstuff {__version__}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _register_commands() -> None:
|
|
24
|
+
from dev_setup.commands.add_cmd import add_cmd
|
|
25
|
+
from dev_setup.commands.catalog_cmd import catalog_cmd
|
|
26
|
+
from dev_setup.commands.delete_cmd import delete_cmd
|
|
27
|
+
from dev_setup.commands.docs_cmd import docs_cmd
|
|
28
|
+
from dev_setup.commands.functions_cmd import functions_cmd
|
|
29
|
+
from dev_setup.commands.install_cmd import install_cmd
|
|
30
|
+
from dev_setup.commands.list_cmd import list_cmd
|
|
31
|
+
from dev_setup.commands.remove_cmd import remove_cmd
|
|
32
|
+
from dev_setup.commands.run_cmd import run_cmd
|
|
33
|
+
from dev_setup.commands.update_cmd import update_cmd
|
|
34
|
+
|
|
35
|
+
cli.add_command(list_cmd, "list")
|
|
36
|
+
cli.add_command(install_cmd, "install")
|
|
37
|
+
cli.add_command(remove_cmd, "remove")
|
|
38
|
+
cli.add_command(remove_cmd, "uninstall")
|
|
39
|
+
cli.add_command(update_cmd, "update")
|
|
40
|
+
cli.add_command(add_cmd, "add")
|
|
41
|
+
cli.add_command(delete_cmd, "delete")
|
|
42
|
+
cli.add_command(delete_cmd, "rm")
|
|
43
|
+
cli.add_command(docs_cmd, "docs")
|
|
44
|
+
cli.add_command(catalog_cmd, "catalog")
|
|
45
|
+
cli.add_command(run_cmd, "run")
|
|
46
|
+
cli.add_command(functions_cmd, "functions")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_register_commands()
|
|
File without changes
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from dev_setup import registry, ui
|
|
8
|
+
from dev_setup.generic import GenericTool
|
|
9
|
+
|
|
10
|
+
_VALID_KEY = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
11
|
+
|
|
12
|
+
_INSTALL_TEMPLATE = """\
|
|
13
|
+
#!/usr/bin/env bash
|
|
14
|
+
# Install script for {name}
|
|
15
|
+
# Save and close to continue. Delete all content (except this line) to abort.
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_REMOVE_TEMPLATE = """\
|
|
21
|
+
#!/usr/bin/env bash
|
|
22
|
+
# Remove script for {name}
|
|
23
|
+
# Save and close to continue. Leave only comments/blank lines to skip removal.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.command("add")
|
|
30
|
+
def add_cmd() -> None:
|
|
31
|
+
"""Add a custom package via guided wizard."""
|
|
32
|
+
ui.section("Add Custom Package")
|
|
33
|
+
|
|
34
|
+
install_type = ui.select(
|
|
35
|
+
"Package type:",
|
|
36
|
+
["npm", "uvx", "apt", "git", "script", "bash"],
|
|
37
|
+
)
|
|
38
|
+
if not install_type:
|
|
39
|
+
ui.warn("Aborted.")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
key = _prompt_key()
|
|
43
|
+
if not key:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
name = ui.text_input("Display name:", default=key, required=True)
|
|
47
|
+
description = ui.text_input("Short description:", required=False)
|
|
48
|
+
|
|
49
|
+
kwargs: dict = dict(
|
|
50
|
+
key=key,
|
|
51
|
+
name=name,
|
|
52
|
+
description=description,
|
|
53
|
+
category="custom",
|
|
54
|
+
install_type=install_type,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if install_type == "npm":
|
|
58
|
+
kwargs["npm_name"] = ui.text_input("npm package name:", default=key, required=True)
|
|
59
|
+
kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
|
|
60
|
+
|
|
61
|
+
elif install_type == "uvx":
|
|
62
|
+
kwargs["pip_name"] = ui.text_input("PyPI package name:", default=key, required=True)
|
|
63
|
+
kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
|
|
64
|
+
|
|
65
|
+
elif install_type == "apt":
|
|
66
|
+
kwargs["apt_packages"] = ui.text_input(
|
|
67
|
+
"apt package(s) (space-separated):", default=key, required=True
|
|
68
|
+
)
|
|
69
|
+
kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
|
|
70
|
+
|
|
71
|
+
elif install_type == "git":
|
|
72
|
+
kwargs["git_url"] = ui.text_input("Git repository URL:", required=True)
|
|
73
|
+
kwargs["git_install_cmd"] = ui.text_input(
|
|
74
|
+
"Post-clone install command (optional, run inside repo):", required=False
|
|
75
|
+
)
|
|
76
|
+
kwargs["git_remove_cmd"] = ui.text_input(
|
|
77
|
+
"Pre-delete remove command (optional):", required=False
|
|
78
|
+
)
|
|
79
|
+
kwargs["check_cmd"] = ui.text_input("Command to check if installed:", required=False)
|
|
80
|
+
|
|
81
|
+
elif install_type == "script":
|
|
82
|
+
kwargs["script_url"] = ui.text_input("Install script URL (curl | sh):", required=True)
|
|
83
|
+
kwargs["check_cmd"] = ui.text_input("Command to check if installed:", required=False)
|
|
84
|
+
|
|
85
|
+
elif install_type == "bash":
|
|
86
|
+
kwargs["check_cmd"] = ui.text_input(
|
|
87
|
+
"Command to verify installation (e.g. bat, aws):", required=False
|
|
88
|
+
)
|
|
89
|
+
install_script = _edit_script("install", name, required=True)
|
|
90
|
+
if install_script is None:
|
|
91
|
+
ui.warn("No install script provided — aborted.")
|
|
92
|
+
return
|
|
93
|
+
kwargs["install_script"] = install_script
|
|
94
|
+
|
|
95
|
+
remove_script = _edit_script("remove", name, required=False)
|
|
96
|
+
remove_script = _validate_remove_script(
|
|
97
|
+
remove_script or "",
|
|
98
|
+
install_script,
|
|
99
|
+
kwargs.get("check_cmd", ""),
|
|
100
|
+
name,
|
|
101
|
+
)
|
|
102
|
+
if remove_script:
|
|
103
|
+
kwargs["remove_script"] = remove_script
|
|
104
|
+
|
|
105
|
+
kwargs["help_cmd"] = ui.text_input(
|
|
106
|
+
"Help command (optional, e.g. tool --help):", required=False
|
|
107
|
+
)
|
|
108
|
+
kwargs["docs_url"] = ui.text_input(
|
|
109
|
+
"Documentation URL (optional):", required=False
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
ui.console.print()
|
|
113
|
+
ui.console.print("[bold]Summary[/]")
|
|
114
|
+
for k, v in kwargs.items():
|
|
115
|
+
if v:
|
|
116
|
+
display = _truncate_script(v) if k.endswith("_script") else v
|
|
117
|
+
ui.console.print(f" [dim]{k:<18}[/] {display}")
|
|
118
|
+
ui.console.print()
|
|
119
|
+
|
|
120
|
+
if not ui.confirm("Save this package?"):
|
|
121
|
+
ui.warn("Aborted — package not saved.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
tool = GenericTool(**kwargs)
|
|
125
|
+
tool.save()
|
|
126
|
+
|
|
127
|
+
from dev_setup import registry
|
|
128
|
+
registry.register(tool)
|
|
129
|
+
|
|
130
|
+
ui.success(f"Package '{key}' added. Install with: devstuff install {key}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _prompt_key() -> str:
|
|
134
|
+
while True:
|
|
135
|
+
key = ui.text_input("Package key (lowercase, hyphens ok):", required=True)
|
|
136
|
+
if not _VALID_KEY.match(key):
|
|
137
|
+
ui.error("Key must be lowercase letters, digits, hyphens, or underscores.")
|
|
138
|
+
continue
|
|
139
|
+
if registry.exists(key):
|
|
140
|
+
ui.error(f"A package with key '{key}' already exists.")
|
|
141
|
+
if not ui.confirm("Use a different key?"):
|
|
142
|
+
return ""
|
|
143
|
+
continue
|
|
144
|
+
return key
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _edit_script(
|
|
148
|
+
action: str,
|
|
149
|
+
package_name: str,
|
|
150
|
+
required: bool = True,
|
|
151
|
+
prefill: str = "",
|
|
152
|
+
quiet: bool = False,
|
|
153
|
+
) -> str | None:
|
|
154
|
+
"""Open $EDITOR with a bash template. Returns stripped script or None if aborted."""
|
|
155
|
+
if prefill:
|
|
156
|
+
template = prefill
|
|
157
|
+
else:
|
|
158
|
+
template = (_INSTALL_TEMPLATE if action == "install" else _REMOVE_TEMPLATE).format(
|
|
159
|
+
name=package_name
|
|
160
|
+
)
|
|
161
|
+
if not quiet:
|
|
162
|
+
label = "install" if action == "install" else "remove (optional)"
|
|
163
|
+
ui.console.print()
|
|
164
|
+
ui.info(f"Opening $EDITOR for the [bold]{label}[/] script...")
|
|
165
|
+
ui.dim("Write your bash commands, save, and close the editor to continue.")
|
|
166
|
+
ui.console.print()
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
content = click.edit(text=template, extension=".sh", require_save=False)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
ui.error(f"Could not open editor: {exc}")
|
|
172
|
+
ui.dim("Set the EDITOR environment variable (e.g. export EDITOR=nano) and try again.")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
if content is None:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
script = _strip_template_comments(content)
|
|
179
|
+
if not script and required:
|
|
180
|
+
ui.error("Install script cannot be empty.")
|
|
181
|
+
return None
|
|
182
|
+
return script or ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _strip_template_comments(content: str) -> str:
|
|
186
|
+
"""Remove comment-only lines and leading/trailing blank lines."""
|
|
187
|
+
lines = [
|
|
188
|
+
line for line in content.splitlines()
|
|
189
|
+
if line.strip() and not line.strip().startswith("#")
|
|
190
|
+
]
|
|
191
|
+
return "\n".join(lines).strip()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_safe_literal_path(path: str) -> bool:
|
|
195
|
+
"""Return True only for fully literal paths — no shell expansion or injection chars."""
|
|
196
|
+
return not any(c in path for c in ('$', '`', '(', ')', ';', '&', '|', ' ', '"', "'", '\\'))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _extract_installed_paths(install_script: str) -> list[str]:
|
|
200
|
+
"""Find definitive binary install destinations (literal paths only)."""
|
|
201
|
+
paths: list[str] = []
|
|
202
|
+
|
|
203
|
+
# curl ... -o /path/binary
|
|
204
|
+
for m in re.finditer(r'\bcurl\b[^|\n]*?-o\s+(\S+)', install_script):
|
|
205
|
+
p = m.group(1).strip()
|
|
206
|
+
if (
|
|
207
|
+
p.startswith(("/", "~/"))
|
|
208
|
+
and _is_safe_literal_path(p)
|
|
209
|
+
and not p.endswith(('.sh', '.tar.gz', '.zip', '.tmp', '.tar', '.bz2', '.gz'))
|
|
210
|
+
):
|
|
211
|
+
paths.append(p)
|
|
212
|
+
|
|
213
|
+
# sudo mv /tmp/x /usr/local/bin/y — destination only
|
|
214
|
+
for m in re.finditer(
|
|
215
|
+
r'\bmv\s+\S+\s+((?:/usr|/opt)[\w./+-]*)', install_script
|
|
216
|
+
):
|
|
217
|
+
p = m.group(1).strip()
|
|
218
|
+
if _is_safe_literal_path(p) and "." not in p.split("/")[-1]:
|
|
219
|
+
paths.append(p)
|
|
220
|
+
|
|
221
|
+
return list(dict.fromkeys(paths))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _suggest_remove_script(install_script: str, check_cmd: str, name: str) -> str:
|
|
225
|
+
"""Generate a best-effort remove script from patterns in the install script."""
|
|
226
|
+
svc_lines: list[str] = []
|
|
227
|
+
actions: list[str] = []
|
|
228
|
+
|
|
229
|
+
# systemd service teardown (must precede file removal)
|
|
230
|
+
for m in re.finditer(r'\bsystemctl\s+(?:enable|start)\s+(\S+)', install_script):
|
|
231
|
+
svc = m.group(1).rstrip(";")
|
|
232
|
+
if not any(svc in a for a in svc_lines):
|
|
233
|
+
svc_lines.append(f"sudo systemctl stop {svc} 2>/dev/null || true")
|
|
234
|
+
svc_lines.append(f"sudo systemctl disable {svc} 2>/dev/null || true")
|
|
235
|
+
|
|
236
|
+
# apt packages
|
|
237
|
+
apt_packages: list[str] = []
|
|
238
|
+
for m in re.finditer(r'\bapt(?:-get)?\s+install\s+(?:-y\s+)?(.+)', install_script):
|
|
239
|
+
for pkg in m.group(1).split():
|
|
240
|
+
if not pkg.startswith("-") and pkg not in apt_packages:
|
|
241
|
+
apt_packages.append(pkg)
|
|
242
|
+
if apt_packages:
|
|
243
|
+
actions.append(f"sudo apt-get remove -y {' '.join(apt_packages)}")
|
|
244
|
+
|
|
245
|
+
# Binary paths from curl / mv (skip /tmp — temp downloads are gone after mv)
|
|
246
|
+
for path in _extract_installed_paths(install_script):
|
|
247
|
+
if path.startswith("/tmp/"):
|
|
248
|
+
continue
|
|
249
|
+
prefix = "sudo " if path.startswith(("/usr/", "/opt/", "/etc/")) else ""
|
|
250
|
+
actions.append(f"{prefix}rm -rf {path}")
|
|
251
|
+
|
|
252
|
+
# Fallback: derive removal from check_cmd if nothing else found
|
|
253
|
+
if not actions and not svc_lines and check_cmd:
|
|
254
|
+
actions.append(f'CMD=$(command -v {check_cmd} 2>/dev/null || true)')
|
|
255
|
+
actions.append('[ -n "$CMD" ] && sudo rm -f "$CMD"')
|
|
256
|
+
|
|
257
|
+
if not svc_lines and not actions:
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
lines = ["#!/usr/bin/env bash", "set -euo pipefail", ""] + svc_lines + actions
|
|
261
|
+
return "\n".join(lines)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _remove_needs_review(remove_script: str, install_script: str) -> bool:
|
|
265
|
+
"""Return True when the remove script warrants a review prompt."""
|
|
266
|
+
if not remove_script.strip():
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
installed_paths = _extract_installed_paths(install_script)
|
|
270
|
+
if not installed_paths:
|
|
271
|
+
return False # can't tell — don't be noisy
|
|
272
|
+
|
|
273
|
+
def _references_target(path: str) -> bool:
|
|
274
|
+
parts = [p for p in path.rstrip("/").split("/") if p]
|
|
275
|
+
candidates = [path]
|
|
276
|
+
if parts:
|
|
277
|
+
candidates.append(parts[-1]) # basename
|
|
278
|
+
for i in range(2, len(parts)): # ancestor dirs
|
|
279
|
+
candidates.append("/" + "/".join(parts[:i]))
|
|
280
|
+
return any(c in remove_script for c in candidates)
|
|
281
|
+
|
|
282
|
+
# Flag only when NONE of the detected targets is referenced
|
|
283
|
+
return not any(_references_target(p) for p in installed_paths)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _validate_remove_script(
|
|
287
|
+
remove_script: str,
|
|
288
|
+
install_script: str,
|
|
289
|
+
check_cmd: str,
|
|
290
|
+
name: str,
|
|
291
|
+
) -> str:
|
|
292
|
+
"""If the remove script looks empty or mismatched, offer a recommended alternative."""
|
|
293
|
+
if not _remove_needs_review(remove_script, install_script):
|
|
294
|
+
return remove_script
|
|
295
|
+
|
|
296
|
+
recommended = _suggest_remove_script(install_script, check_cmd, name)
|
|
297
|
+
|
|
298
|
+
ui.console.print()
|
|
299
|
+
if not remove_script.strip():
|
|
300
|
+
ui.warn("Remove script is empty — uninstalling will fail without one.")
|
|
301
|
+
else:
|
|
302
|
+
ui.warn("Remove script may not undo the installation (possible path mismatch).")
|
|
303
|
+
|
|
304
|
+
if recommended:
|
|
305
|
+
ui.console.print()
|
|
306
|
+
ui.info("Suggested remove script:")
|
|
307
|
+
ui.console.print()
|
|
308
|
+
ui.code_block(recommended)
|
|
309
|
+
ui.console.print()
|
|
310
|
+
|
|
311
|
+
choices: list[str] = []
|
|
312
|
+
if recommended:
|
|
313
|
+
choices.append("Use recommended")
|
|
314
|
+
choices.append("Keep mine (empty)" if not remove_script.strip() else "Keep mine")
|
|
315
|
+
choices.append("Edit manually")
|
|
316
|
+
|
|
317
|
+
choice = ui.select("How would you like to proceed?", choices)
|
|
318
|
+
|
|
319
|
+
if choice == "Use recommended":
|
|
320
|
+
return _strip_template_comments(recommended)
|
|
321
|
+
if choice == "Edit manually":
|
|
322
|
+
prefill = recommended or remove_script or _REMOVE_TEMPLATE.format(name=name)
|
|
323
|
+
edited = _edit_script("remove", name, required=False, prefill=prefill, quiet=True)
|
|
324
|
+
return edited if edited is not None else remove_script
|
|
325
|
+
return remove_script # "Keep mine" / "Keep mine (empty)"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _truncate_script(script: str) -> str:
|
|
329
|
+
lines = [ln for ln in script.splitlines() if ln.strip()]
|
|
330
|
+
if not lines:
|
|
331
|
+
return "[dim](empty)[/]"
|
|
332
|
+
first = lines[0][:60]
|
|
333
|
+
suffix = f" [dim]+{len(lines) - 1} more lines[/]" if len(lines) > 1 else ""
|
|
334
|
+
return f"{first}{suffix}"
|