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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from dev_setup import catalog, ui
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group("catalog")
|
|
11
|
+
def catalog_cmd() -> None:
|
|
12
|
+
"""Manage YAML tool catalogs."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@catalog_cmd.command("path")
|
|
16
|
+
def path_cmd() -> None:
|
|
17
|
+
"""Print the user catalog path."""
|
|
18
|
+
click.echo(catalog.USER_CATALOG_PATH)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@catalog_cmd.command("export")
|
|
22
|
+
@click.argument("path", required=False, type=click.Path(path_type=Path))
|
|
23
|
+
def export_cmd(path: Path | None) -> None:
|
|
24
|
+
"""Export the effective catalog to PATH."""
|
|
25
|
+
out = path or Path("dev-setup-tools.yaml")
|
|
26
|
+
catalog.export_catalog(out)
|
|
27
|
+
ui.success(f"Exported catalog to {out}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@catalog_cmd.command("import")
|
|
31
|
+
@click.argument("path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
32
|
+
def import_cmd(path: Path) -> None:
|
|
33
|
+
"""Import and merge a YAML catalog into the user catalog."""
|
|
34
|
+
keys = catalog.import_catalog(path)
|
|
35
|
+
ui.success(f"Imported {len(keys)} package(s) into {catalog.USER_CATALOG_PATH}")
|
|
36
|
+
|
|
37
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from dev_setup import catalog, registry, ui
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command("delete")
|
|
11
|
+
@click.argument("key")
|
|
12
|
+
def delete_cmd(key: str) -> None:
|
|
13
|
+
"""Remove a custom package from the registry."""
|
|
14
|
+
if not registry.exists(key):
|
|
15
|
+
ui.error(f"No package with key '{key}' found.")
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
|
|
18
|
+
tool = registry.get(key)
|
|
19
|
+
assert tool is not None
|
|
20
|
+
|
|
21
|
+
if not catalog.user_has_tool(key):
|
|
22
|
+
ui.error(f"'{key}' is a built-in package and cannot be deleted.")
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
ui.console.print(f"\n [bold]{tool.name}[/] — {tool.description}\n")
|
|
26
|
+
|
|
27
|
+
if not ui.confirm(f"Delete user catalog entry '{key}'?", default=False):
|
|
28
|
+
ui.dim("Aborted.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
catalog.delete_user_tool(key)
|
|
32
|
+
registry.reload()
|
|
33
|
+
if registry.exists(key):
|
|
34
|
+
ui.success(f"User override for '{key}' removed — built-in version is now active.")
|
|
35
|
+
else:
|
|
36
|
+
ui.success(f"Package '{key}' deleted from user catalog.")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from dev_setup import registry, ui
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command("docs")
|
|
12
|
+
@click.argument("package")
|
|
13
|
+
def docs_cmd(package: str) -> None:
|
|
14
|
+
"""Open the documentation site for a package in your browser."""
|
|
15
|
+
if not registry.exists(package):
|
|
16
|
+
ui.error(f"Unknown package: '{package}'")
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
tool = registry.get(package)
|
|
20
|
+
assert tool is not None
|
|
21
|
+
|
|
22
|
+
if not tool.docs_url:
|
|
23
|
+
ui.warn(f"No documentation URL is configured for '{package}'.")
|
|
24
|
+
ui.dim("For custom packages, add docs_url in your YAML catalog.")
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
ui.console.print(f"\n [bold]{tool.name}[/] docs")
|
|
28
|
+
ui.console.print(f" [cyan]{tool.docs_url}[/]\n")
|
|
29
|
+
|
|
30
|
+
opened = webbrowser.open(tool.docs_url)
|
|
31
|
+
if opened:
|
|
32
|
+
ui.success("Opened in browser.")
|
|
33
|
+
else:
|
|
34
|
+
ui.warn("Could not open a browser — copy the URL above to open manually.")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from dev_setup import function_runner as runner
|
|
8
|
+
from dev_setup import functions_registry, ui
|
|
9
|
+
from dev_setup.functions_catalog import USER_CATALOG_PATH
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group("functions")
|
|
13
|
+
def functions_cmd() -> None:
|
|
14
|
+
"""Manage functions/scripts. Invoke one with: devstuff run <key>."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@functions_cmd.command("list")
|
|
18
|
+
def list_cmd() -> None:
|
|
19
|
+
"""List available functions."""
|
|
20
|
+
fns = functions_registry.all_functions()
|
|
21
|
+
if not fns:
|
|
22
|
+
ui.info("No functions defined.")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
by_cat: dict[str, list] = {}
|
|
26
|
+
for f in fns:
|
|
27
|
+
by_cat.setdefault(f.category, []).append(f)
|
|
28
|
+
|
|
29
|
+
key_width = max((len(f.key) for f in fns), default=12) + 2
|
|
30
|
+
for cat in sorted(by_cat, key=lambda c: (c == "custom", c)):
|
|
31
|
+
ui.console.print(f"\n [bold]{cat.upper()}[/]")
|
|
32
|
+
for f in sorted(by_cat[cat], key=lambda f: f.key):
|
|
33
|
+
mode = f.type if f.type == "script" else f"{f.type} ({f.register})"
|
|
34
|
+
ui.console.print(
|
|
35
|
+
f" [bold cyan]{f.key:<{key_width}}[/] [dim]{mode:<22}[/] {f.description}"
|
|
36
|
+
)
|
|
37
|
+
params = " ".join(f"<{p.name}>" for p in f.params)
|
|
38
|
+
if params:
|
|
39
|
+
ui.dim(f" args: {params}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@functions_cmd.command("enable")
|
|
43
|
+
@click.argument("key")
|
|
44
|
+
def enable_cmd(key: str) -> None:
|
|
45
|
+
"""Register a bashrc-backed function into ~/.bashrc."""
|
|
46
|
+
fn = functions_registry.get(key)
|
|
47
|
+
if fn is None:
|
|
48
|
+
ui.error(f"Unknown function: '{key}'")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
if not (fn.type == "shell-eval" and fn.register == "bashrc"):
|
|
51
|
+
ui.error(f"'{fn.key}' does not use bashrc registration.")
|
|
52
|
+
if fn.type == "script":
|
|
53
|
+
ui.dim(f"Run it directly: devstuff run {fn.key}")
|
|
54
|
+
else:
|
|
55
|
+
ui.dim(f'Use: eval "$(devstuff run {fn.key} ...)"')
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
added = runner.enable_bashrc_function(fn)
|
|
59
|
+
if added:
|
|
60
|
+
ui.success(f"Registered '{fn.key}' in ~/.bashrc")
|
|
61
|
+
ui.dim("Run: source ~/.bashrc (or open a new shell)")
|
|
62
|
+
args = " ".join(f"<{p.name}>" for p in fn.params)
|
|
63
|
+
ui.dim(f"Then call it directly: {fn.key} {args}".rstrip())
|
|
64
|
+
else:
|
|
65
|
+
ui.info(f"'{fn.key}' is already registered in ~/.bashrc")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@functions_cmd.command("disable")
|
|
69
|
+
@click.argument("key")
|
|
70
|
+
def disable_cmd(key: str) -> None:
|
|
71
|
+
"""Remove a bashrc-backed function from ~/.bashrc."""
|
|
72
|
+
fn = functions_registry.get(key)
|
|
73
|
+
if fn is None:
|
|
74
|
+
ui.error(f"Unknown function: '{key}'")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
removed = runner.disable_bashrc_function(fn)
|
|
78
|
+
if removed:
|
|
79
|
+
ui.success(f"Removed '{fn.key}' from ~/.bashrc")
|
|
80
|
+
else:
|
|
81
|
+
ui.info(f"'{fn.key}' is not registered in ~/.bashrc")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@functions_cmd.command("path")
|
|
85
|
+
def path_cmd() -> None:
|
|
86
|
+
"""Print the path to the user functions catalog."""
|
|
87
|
+
click.echo(str(USER_CATALOG_PATH))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from dev_setup import ui
|
|
2
|
+
from dev_setup.catalog import USER_CATALOG_PATH
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def print_help() -> None:
|
|
6
|
+
ui.print_banner()
|
|
7
|
+
ui.console.print("[bold]USAGE[/]")
|
|
8
|
+
ui.console.print(" devstuff [bold cyan]<command>[/] [OPTIONS] [ARGS]\n")
|
|
9
|
+
|
|
10
|
+
ui.console.print("[bold]COMMANDS[/]")
|
|
11
|
+
rows = [
|
|
12
|
+
("list", "[--installed] [--available] [category]", "List packages"),
|
|
13
|
+
("install", "[package ...]", "Install packages (interactive if no args)"),
|
|
14
|
+
("remove", "<package ...>", "Uninstall installed packages"),
|
|
15
|
+
("update", "[package ...] [--version]", "Update packages (interactive if no args)"),
|
|
16
|
+
("add", "", "Add a custom package (guided wizard)"),
|
|
17
|
+
("delete", "<key>", "Remove a custom package from the registry"),
|
|
18
|
+
("catalog", "<path|export|import>", "Manage YAML tool catalogs"),
|
|
19
|
+
("docs", "<package>", "Open documentation in browser"),
|
|
20
|
+
("run", "<function> [args...]", "Run a function/script"),
|
|
21
|
+
("functions", "<list|enable|disable|path>", "Manage functions/scripts"),
|
|
22
|
+
("version", "", "Show version"),
|
|
23
|
+
]
|
|
24
|
+
for cmd, args, desc in rows:
|
|
25
|
+
ui.console.print(
|
|
26
|
+
f" [bold cyan]{cmd:<10}[/] [dim]{args:<40}[/] {desc}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
ui.console.print()
|
|
30
|
+
ui.console.print("[bold]EXAMPLES[/]")
|
|
31
|
+
examples = [
|
|
32
|
+
"devstuff list",
|
|
33
|
+
"devstuff list core",
|
|
34
|
+
"devstuff list --installed",
|
|
35
|
+
"devstuff install docker nvm",
|
|
36
|
+
"devstuff install",
|
|
37
|
+
"devstuff remove htop",
|
|
38
|
+
"devstuff update nvm",
|
|
39
|
+
"devstuff update pi --version 1.2.3",
|
|
40
|
+
"devstuff update",
|
|
41
|
+
"devstuff add",
|
|
42
|
+
"devstuff delete my-tool",
|
|
43
|
+
"devstuff catalog export",
|
|
44
|
+
"devstuff functions list",
|
|
45
|
+
"devstuff functions enable ssh-agent-key",
|
|
46
|
+
"ssh-agent-key ~/.ssh/id_ed25519",
|
|
47
|
+
]
|
|
48
|
+
for ex in examples:
|
|
49
|
+
ui.console.print(f" [dim]$[/] [green]{ex}[/]")
|
|
50
|
+
|
|
51
|
+
ui.console.print()
|
|
52
|
+
ui.console.print("[bold]CATEGORIES[/]")
|
|
53
|
+
ui.console.print(" [cyan]core[/] Always-installed tools (Docker, NVM, uv)")
|
|
54
|
+
ui.console.print(" [cyan]tools[/] Optional utilities (PHP, Starship, htop)")
|
|
55
|
+
ui.console.print(" [cyan]languages[/] Programming language runtimes")
|
|
56
|
+
ui.console.print(" [cyan]custom[/] User-added packages\n")
|
|
57
|
+
|
|
58
|
+
ui.console.print("[bold]CONFIG[/]")
|
|
59
|
+
ui.console.print(f" User catalog: [dim]{USER_CATALOG_PATH}[/]\n")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import questionary
|
|
8
|
+
|
|
9
|
+
from dev_setup import registry, ui
|
|
10
|
+
from dev_setup.base import Tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("install")
|
|
14
|
+
@click.option("--verbose", "-v", is_flag=True, help="Stream install output to the terminal.")
|
|
15
|
+
@click.argument("packages", nargs=-1)
|
|
16
|
+
def install_cmd(packages: tuple[str, ...], verbose: bool) -> None:
|
|
17
|
+
"""Install packages. Interactive picker when called with no arguments."""
|
|
18
|
+
from dev_setup import generic
|
|
19
|
+
generic._verbose = verbose
|
|
20
|
+
|
|
21
|
+
if not packages:
|
|
22
|
+
_install_interactive()
|
|
23
|
+
else:
|
|
24
|
+
failed = False
|
|
25
|
+
for key in packages:
|
|
26
|
+
if not registry.exists(key):
|
|
27
|
+
ui.error(f"Unknown package: '{key}'")
|
|
28
|
+
failed = True
|
|
29
|
+
continue
|
|
30
|
+
if not _install_one(registry.get(key)): # type: ignore[arg-type]
|
|
31
|
+
failed = True
|
|
32
|
+
if failed:
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _install_one(tool: Tool) -> bool:
|
|
37
|
+
ui.section(tool.name)
|
|
38
|
+
if tool.is_installed():
|
|
39
|
+
ui.success(f"{tool.name} is already installed: {tool.get_version()}")
|
|
40
|
+
return True
|
|
41
|
+
missing = registry.missing_requires(tool)
|
|
42
|
+
if missing:
|
|
43
|
+
ui.error(f"Cannot install {tool.name} — missing required tools: {', '.join(missing)}")
|
|
44
|
+
ui.dim(f"Install first: devstuff install {' '.join(missing)}")
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
version = tool.install()
|
|
48
|
+
msg = f"{tool.name} installed"
|
|
49
|
+
if version:
|
|
50
|
+
msg += f": {version}"
|
|
51
|
+
ui.success(msg)
|
|
52
|
+
return True
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
ui.error(f"Failed to install {tool.name}: {exc}")
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _install_interactive() -> None:
|
|
59
|
+
ui.print_banner()
|
|
60
|
+
tools = registry.all_tools()
|
|
61
|
+
|
|
62
|
+
# Probe installed status concurrently — each check shells out.
|
|
63
|
+
with ui.spinner("Checking installed packages..."), ThreadPoolExecutor(max_workers=8) as pool:
|
|
64
|
+
statuses = pool.map(lambda t: t.is_installed(), tools)
|
|
65
|
+
installed = dict(zip((t.key for t in tools), statuses, strict=True))
|
|
66
|
+
|
|
67
|
+
by_cat: dict[str, list[Tool]] = {}
|
|
68
|
+
for t in tools:
|
|
69
|
+
by_cat.setdefault(t.category, []).append(t)
|
|
70
|
+
|
|
71
|
+
key_width = max((len(t.key) for t in tools), default=12) + 2
|
|
72
|
+
desc_width = max(20, ui.console.width - key_width - 14)
|
|
73
|
+
|
|
74
|
+
choices: list = []
|
|
75
|
+
_ORDER = {"core": 0, "tools": 1, "custom": 999}
|
|
76
|
+
for cat in sorted(by_cat, key=lambda c: (_ORDER.get(c, 500), c)):
|
|
77
|
+
entries = sorted(by_cat[cat], key=lambda t: t.key)
|
|
78
|
+
n_inst = sum(installed[t.key] for t in entries)
|
|
79
|
+
choices.append(questionary.Separator(
|
|
80
|
+
f"\n {cat.upper()} ({n_inst}/{len(entries)} installed)"
|
|
81
|
+
))
|
|
82
|
+
for t in entries:
|
|
83
|
+
is_inst = installed[t.key]
|
|
84
|
+
missing = [] if is_inst else registry.missing_requires(t)
|
|
85
|
+
desc = t.description
|
|
86
|
+
if len(desc) > desc_width:
|
|
87
|
+
desc = desc[: desc_width - 1] + "…"
|
|
88
|
+
if is_inst:
|
|
89
|
+
disabled = True
|
|
90
|
+
elif missing:
|
|
91
|
+
disabled = f"requires {', '.join(missing)}"
|
|
92
|
+
else:
|
|
93
|
+
disabled = None
|
|
94
|
+
title = [
|
|
95
|
+
("class:check", "✔ " if is_inst else " "),
|
|
96
|
+
("class:text", f"{t.key:<{key_width}}{desc}"),
|
|
97
|
+
]
|
|
98
|
+
choices.append(questionary.Choice(
|
|
99
|
+
title=title,
|
|
100
|
+
value=t.key,
|
|
101
|
+
checked=False,
|
|
102
|
+
disabled=disabled,
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
selected = questionary.checkbox(
|
|
106
|
+
"Select packages to install:",
|
|
107
|
+
choices=choices,
|
|
108
|
+
instruction="(Space toggle · Enter confirm · installed items are skipped)",
|
|
109
|
+
style=ui._STYLE,
|
|
110
|
+
).ask()
|
|
111
|
+
|
|
112
|
+
if not selected:
|
|
113
|
+
ui.info("No packages selected.")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
to_install = selected
|
|
117
|
+
|
|
118
|
+
if not ui.confirm(f"Install {len(to_install)} package(s)?"):
|
|
119
|
+
ui.warn("Aborted.")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
failed = False
|
|
123
|
+
for key in to_install:
|
|
124
|
+
tool = registry.get(key)
|
|
125
|
+
if tool and not _install_one(tool):
|
|
126
|
+
failed = True
|
|
127
|
+
|
|
128
|
+
if failed:
|
|
129
|
+
sys.exit(1)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from dev_setup import registry, ui
|
|
9
|
+
from dev_setup.base import Tool
|
|
10
|
+
|
|
11
|
+
_MAX_WORKERS = 8
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _probe(tool: Tool) -> tuple[Tool, bool, str]:
|
|
15
|
+
"""Gather subprocess-heavy status for one tool (runs in a worker thread)."""
|
|
16
|
+
is_inst = tool.is_installed()
|
|
17
|
+
version = tool.get_version() if is_inst else ""
|
|
18
|
+
return tool, is_inst, version
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command("list")
|
|
22
|
+
@click.option("--installed", "show_filter", flag_value="installed", help="Show only installed packages")
|
|
23
|
+
@click.option("--available", "show_filter", flag_value="available", help="Show only uninstalled packages")
|
|
24
|
+
@click.argument("category", required=False)
|
|
25
|
+
def list_cmd(show_filter: str, category: str) -> None:
|
|
26
|
+
"""List available packages."""
|
|
27
|
+
ui.print_banner()
|
|
28
|
+
|
|
29
|
+
tools = registry.all_tools()
|
|
30
|
+
if category:
|
|
31
|
+
tools = [t for t in tools if t.category == category]
|
|
32
|
+
|
|
33
|
+
# Probe installed status + version concurrently — each check shells out,
|
|
34
|
+
# so this dominates the command's runtime when done serially.
|
|
35
|
+
with ThreadPoolExecutor(max_workers=_MAX_WORKERS) as pool:
|
|
36
|
+
probed = list(pool.map(_probe, tools))
|
|
37
|
+
|
|
38
|
+
by_cat: dict = {}
|
|
39
|
+
for tool, is_inst, version in probed:
|
|
40
|
+
if show_filter == "installed" and not is_inst:
|
|
41
|
+
continue
|
|
42
|
+
if show_filter == "available" and is_inst:
|
|
43
|
+
continue
|
|
44
|
+
by_cat.setdefault(tool.category, []).append((tool, is_inst, version))
|
|
45
|
+
|
|
46
|
+
if not by_cat:
|
|
47
|
+
ui.warn("No packages match the given filters.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
_ORDER = {"core": 0, "tools": 1, "custom": 999}
|
|
51
|
+
for cat in sorted(by_cat, key=lambda c: (_ORDER.get(c, 500), c)):
|
|
52
|
+
entries = sorted(by_cat.get(cat, []), key=lambda e: e[0].key)
|
|
53
|
+
if not entries:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
ui.console.print(f" [bold magenta]{cat.upper()}[/]")
|
|
57
|
+
ui.divider()
|
|
58
|
+
|
|
59
|
+
tbl = Table(box=None, padding=(0, 1), show_header=True, header_style="dim")
|
|
60
|
+
tbl.add_column("", width=2)
|
|
61
|
+
tbl.add_column("Package", style="bold", min_width=12)
|
|
62
|
+
tbl.add_column("Description", min_width=36)
|
|
63
|
+
tbl.add_column("Type", style="dim", min_width=8)
|
|
64
|
+
tbl.add_column("Version", style="dim")
|
|
65
|
+
|
|
66
|
+
for tool, is_inst, version in entries:
|
|
67
|
+
missing = registry.missing_requires(tool) if not is_inst else []
|
|
68
|
+
icon = "[green bold]✔[/]" if is_inst else "[red bold]✘[/]"
|
|
69
|
+
tbl.add_row(icon, tool.key, tool.description, tool.install_type, version)
|
|
70
|
+
if tool.help_cmd:
|
|
71
|
+
tbl.add_row("", "", f"[dim cyan] ? {tool.help_cmd}[/]", "", "")
|
|
72
|
+
if missing:
|
|
73
|
+
tbl.add_row("", "", f"[yellow] ⚠ requires: {', '.join(missing)}[/]", "", "")
|
|
74
|
+
|
|
75
|
+
ui.console.print(tbl)
|
|
76
|
+
ui.console.print()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from dev_setup import registry, ui
|
|
8
|
+
from dev_setup.base import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command("remove")
|
|
12
|
+
@click.option("--verbose", "-v", is_flag=True, help="Stream removal output to the terminal.")
|
|
13
|
+
@click.argument("packages", nargs=-1)
|
|
14
|
+
def remove_cmd(packages: tuple[str, ...], verbose: bool) -> None:
|
|
15
|
+
"""Uninstall installed packages."""
|
|
16
|
+
from dev_setup import generic
|
|
17
|
+
generic._verbose = verbose
|
|
18
|
+
|
|
19
|
+
if not packages:
|
|
20
|
+
ui.error("Specify at least one package key. See: devstuff list --installed")
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
failed = False
|
|
24
|
+
for key in packages:
|
|
25
|
+
if not registry.exists(key):
|
|
26
|
+
ui.error(f"Unknown package: '{key}'")
|
|
27
|
+
failed = True
|
|
28
|
+
continue
|
|
29
|
+
if not _remove_one(registry.get(key)): # type: ignore[arg-type]
|
|
30
|
+
failed = True
|
|
31
|
+
|
|
32
|
+
if failed:
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _remove_one(tool: Tool) -> bool:
|
|
37
|
+
ui.section(f"Remove {tool.name}")
|
|
38
|
+
|
|
39
|
+
if not tool.is_installed():
|
|
40
|
+
ui.warn(f"{tool.name} is not installed — nothing to remove.")
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
if not ui.confirm(f"Remove {tool.name}?", default=False):
|
|
44
|
+
ui.dim("Skipped.")
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
tool.remove()
|
|
49
|
+
ui.success(f"{tool.name} removed")
|
|
50
|
+
return True
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
ui.error(f"Failed to remove {tool.name}: {exc}")
|
|
53
|
+
return False
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from dev_setup import function_runner as runner
|
|
8
|
+
from dev_setup import functions_registry, ui
|
|
9
|
+
from dev_setup.function_runner import ParamResolutionError
|
|
10
|
+
from dev_setup.functions_registry import FunctionDef, FunctionParam
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("run")
|
|
14
|
+
@click.argument("key")
|
|
15
|
+
@click.argument("args", nargs=-1)
|
|
16
|
+
def run_cmd(key: str, args: tuple[str, ...]) -> None:
|
|
17
|
+
"""Run a function/script. See: devstuff functions list"""
|
|
18
|
+
fn = functions_registry.get(key)
|
|
19
|
+
if fn is None:
|
|
20
|
+
ui.error(f"Unknown function: '{key}'")
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
if fn.type == "script":
|
|
24
|
+
_run_script(fn, args)
|
|
25
|
+
elif fn.type == "shell-eval" and fn.register == "eval":
|
|
26
|
+
_run_eval(fn, args)
|
|
27
|
+
else: # shell-eval + bashrc — dev-setup can't mutate the calling shell itself
|
|
28
|
+
ui.error(f"'{fn.key}' is registered via ~/.bashrc, not run directly.")
|
|
29
|
+
ui.dim(f"Enable it once: devstuff functions enable {fn.key}")
|
|
30
|
+
ui.dim(f"Then call it directly in your shell: {fn.key} ...")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _prompt_param(p: FunctionParam) -> str:
|
|
35
|
+
label = p.description or p.name
|
|
36
|
+
suffix = "" if p.required else " (optional)"
|
|
37
|
+
return ui.text_input(f"{label}{suffix}:", default=p.default, required=p.required)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_script(fn: FunctionDef, args: tuple[str, ...]) -> None:
|
|
41
|
+
ui.section(fn.name)
|
|
42
|
+
# Only prompt when there's an actual terminal to prompt on — otherwise a missing
|
|
43
|
+
# required param would hit an unreadable stdin and fail as an opaque, unrelated
|
|
44
|
+
# exception instead of the clean "Missing required parameter(s)" message.
|
|
45
|
+
prompt = _prompt_param if sys.stdin.isatty() else None
|
|
46
|
+
try:
|
|
47
|
+
runner.run_script_function(fn, args, prompt=prompt)
|
|
48
|
+
ui.success(f"{fn.name} completed")
|
|
49
|
+
except ParamResolutionError as exc:
|
|
50
|
+
ui.error(str(exc))
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
ui.error(f"'{fn.name}' failed: {exc}")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_eval(fn: FunctionDef, args: tuple[str, ...]) -> None:
|
|
58
|
+
# Diagnostics must go to stderr and stdout must carry ONLY the resolved script —
|
|
59
|
+
# the caller does `eval "$(dev-setup run ...)"`, so anything else printed to
|
|
60
|
+
# stdout becomes part of what gets evaluated in their shell.
|
|
61
|
+
try:
|
|
62
|
+
script = runner.render_eval_script(fn, args)
|
|
63
|
+
except ParamResolutionError as exc:
|
|
64
|
+
click.echo(str(exc), err=True)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
click.echo(script)
|