devstuff 2.0.0__py3-none-any.whl → 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dev_setup/cli.py +28 -1
- dev_setup/commands/install_cmd.py +1 -5
- dev_setup/commands/remove_cmd.py +1 -5
- dev_setup/commands/run_cmd.py +10 -1
- dev_setup/commands/update_cmd.py +1 -5
- dev_setup/function_runner.py +27 -6
- dev_setup/functions.yaml +129 -0
- dev_setup/generic.py +86 -69
- dev_setup/verbose.py +148 -0
- {devstuff-2.0.0.dist-info → devstuff-2.2.0.dist-info}/METADATA +54 -1
- {devstuff-2.0.0.dist-info → devstuff-2.2.0.dist-info}/RECORD +13 -12
- {devstuff-2.0.0.dist-info → devstuff-2.2.0.dist-info}/WHEEL +0 -0
- {devstuff-2.0.0.dist-info → devstuff-2.2.0.dist-info}/entry_points.txt +0 -0
dev_setup/cli.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
|
-
from dev_setup import __version__
|
|
3
|
+
from dev_setup import __version__, verbose
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
@click.group(
|
|
7
7
|
invoke_without_command=True,
|
|
8
8
|
context_settings={"help_option_names": ["-h", "--help"]},
|
|
9
9
|
)
|
|
10
|
+
@verbose.option
|
|
10
11
|
@click.pass_context
|
|
11
12
|
def cli(ctx: click.Context) -> None:
|
|
12
13
|
if ctx.invoked_subcommand is None:
|
|
@@ -54,5 +55,31 @@ def _register_commands() -> None:
|
|
|
54
55
|
cli.add_command(skills_cmd, "skills")
|
|
55
56
|
cli.add_command(agent_cmd, "agent")
|
|
56
57
|
|
|
58
|
+
_add_verbose_option(cli)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _add_verbose_option(group: click.Group) -> None:
|
|
62
|
+
"""Give every command and subcommand `-v`/`-vv`, recursively.
|
|
63
|
+
|
|
64
|
+
Done centrally rather than by decorating each command so that the flag is accepted
|
|
65
|
+
everywhere it could plausibly be typed — `devstuff -v install x`, `devstuff install
|
|
66
|
+
-v x`, `devstuff functions -v enable k` all work, and no command can be forgotten.
|
|
67
|
+
Aliases (`remove`/`uninstall`) share one Command object, hence the seen-set.
|
|
68
|
+
"""
|
|
69
|
+
seen: set[int] = set()
|
|
70
|
+
|
|
71
|
+
def walk(cmd: click.Command) -> None:
|
|
72
|
+
if id(cmd) in seen:
|
|
73
|
+
return
|
|
74
|
+
seen.add(id(cmd))
|
|
75
|
+
if not any("--verbose" in p.opts for p in cmd.params):
|
|
76
|
+
verbose.option(cmd)
|
|
77
|
+
if isinstance(cmd, click.Group):
|
|
78
|
+
for sub in cmd.commands.values():
|
|
79
|
+
walk(sub)
|
|
80
|
+
|
|
81
|
+
for command in group.commands.values():
|
|
82
|
+
walk(command)
|
|
83
|
+
|
|
57
84
|
|
|
58
85
|
_register_commands()
|
|
@@ -11,13 +11,9 @@ from dev_setup.base import Tool
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@click.command("install")
|
|
14
|
-
@click.option("--verbose", "-v", is_flag=True, help="Stream install output to the terminal.")
|
|
15
14
|
@click.argument("packages", nargs=-1)
|
|
16
|
-
def install_cmd(packages: tuple[str, ...]
|
|
15
|
+
def install_cmd(packages: tuple[str, ...]) -> None:
|
|
17
16
|
"""Install packages. Interactive picker when called with no arguments."""
|
|
18
|
-
from dev_setup import generic
|
|
19
|
-
generic._verbose = verbose
|
|
20
|
-
|
|
21
17
|
if not packages:
|
|
22
18
|
_install_interactive()
|
|
23
19
|
else:
|
dev_setup/commands/remove_cmd.py
CHANGED
|
@@ -9,13 +9,9 @@ from dev_setup.base import Tool
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@click.command("remove")
|
|
12
|
-
@click.option("--verbose", "-v", is_flag=True, help="Stream removal output to the terminal.")
|
|
13
12
|
@click.argument("packages", nargs=-1)
|
|
14
|
-
def remove_cmd(packages: tuple[str, ...]
|
|
13
|
+
def remove_cmd(packages: tuple[str, ...]) -> None:
|
|
15
14
|
"""Uninstall installed packages."""
|
|
16
|
-
from dev_setup import generic
|
|
17
|
-
generic._verbose = verbose
|
|
18
|
-
|
|
19
15
|
if not packages:
|
|
20
16
|
ui.error("Specify at least one package key. See: devstuff list --installed")
|
|
21
17
|
sys.exit(1)
|
dev_setup/commands/run_cmd.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import subprocess
|
|
3
4
|
import sys
|
|
4
5
|
|
|
5
6
|
import click
|
|
6
7
|
|
|
7
8
|
from dev_setup import function_runner as runner
|
|
8
|
-
from dev_setup import functions_registry, ui
|
|
9
|
+
from dev_setup import functions_registry, ui, verbose
|
|
9
10
|
from dev_setup.function_runner import ParamResolutionError
|
|
10
11
|
from dev_setup.functions_registry import FunctionDef, FunctionParam
|
|
11
12
|
|
|
@@ -20,6 +21,8 @@ def run_cmd(key: str, args: tuple[str, ...]) -> None:
|
|
|
20
21
|
ui.error(f"Unknown function: '{key}'")
|
|
21
22
|
sys.exit(1)
|
|
22
23
|
|
|
24
|
+
verbose.trace(f"function '{fn.key}': type={fn.type} register={fn.register} args={list(args)}")
|
|
25
|
+
|
|
23
26
|
if fn.type == "script":
|
|
24
27
|
_run_script(fn, args)
|
|
25
28
|
elif fn.type == "shell-eval" and fn.register == "eval":
|
|
@@ -49,6 +52,12 @@ def _run_script(fn: FunctionDef, args: tuple[str, ...]) -> None:
|
|
|
49
52
|
except ParamResolutionError as exc:
|
|
50
53
|
ui.error(str(exc))
|
|
51
54
|
sys.exit(1)
|
|
55
|
+
except subprocess.CalledProcessError as exc:
|
|
56
|
+
# devstuff's own exit code is always 1 here (a function can't signal a result
|
|
57
|
+
# through it), so the script's real exit code is only visible under -v.
|
|
58
|
+
ui.error(f"'{fn.name}' failed: {exc}")
|
|
59
|
+
verbose.log(f"'{fn.key}' exited {exc.returncode}")
|
|
60
|
+
sys.exit(1)
|
|
52
61
|
except Exception as exc:
|
|
53
62
|
ui.error(f"'{fn.name}' failed: {exc}")
|
|
54
63
|
sys.exit(1)
|
dev_setup/commands/update_cmd.py
CHANGED
|
@@ -12,17 +12,13 @@ from dev_setup.generic import UpdateStatus
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@click.command("update")
|
|
15
|
-
@click.option("--verbose", "-v", is_flag=True, help="Stream update output to the terminal.")
|
|
16
15
|
@click.option(
|
|
17
16
|
"--version", "target_version", default=None,
|
|
18
17
|
help="Update to a specific version instead of latest. Only valid with a single package.",
|
|
19
18
|
)
|
|
20
19
|
@click.argument("packages", nargs=-1)
|
|
21
|
-
def update_cmd(packages: tuple[str, ...],
|
|
20
|
+
def update_cmd(packages: tuple[str, ...], target_version: str | None) -> None:
|
|
22
21
|
"""Update packages. Interactive picker with recommended updates when called with no arguments."""
|
|
23
|
-
from dev_setup import generic
|
|
24
|
-
generic._verbose = verbose
|
|
25
|
-
|
|
26
22
|
if not packages:
|
|
27
23
|
if target_version:
|
|
28
24
|
ui.error("--version requires a package key.")
|
dev_setup/function_runner.py
CHANGED
|
@@ -6,6 +6,7 @@ import subprocess
|
|
|
6
6
|
import tempfile
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
|
|
9
|
+
from dev_setup import verbose
|
|
9
10
|
from dev_setup.base import patch_bashrc, remove_bashrc_block
|
|
10
11
|
from dev_setup.functions_registry import FunctionDef, FunctionParam
|
|
11
12
|
|
|
@@ -70,7 +71,12 @@ def render_eval_script(fn: FunctionDef, args: tuple[str, ...]) -> str:
|
|
|
70
71
|
clean for `eval "$(...)"` capture, so missing required params raise instead."""
|
|
71
72
|
values = resolve_params(fn.params, args)
|
|
72
73
|
prelude = _literal_prelude(fn.params, values)
|
|
73
|
-
|
|
74
|
+
script = f"{prelude}\n{fn.script}" if prelude else fn.script
|
|
75
|
+
# Logged, never traced with `set -x`: this text is evaluated by the *caller's*
|
|
76
|
+
# interactive shell, where enabling xtrace would persist after the function returns.
|
|
77
|
+
verbose.log(f"eval script for '{fn.key}':")
|
|
78
|
+
verbose.block(script, minimum=verbose.VERBOSE)
|
|
79
|
+
return script
|
|
74
80
|
|
|
75
81
|
|
|
76
82
|
def _bashrc_prelude(fn: FunctionDef) -> list[str]:
|
|
@@ -106,7 +112,10 @@ def render_bashrc_function(fn: FunctionDef) -> str:
|
|
|
106
112
|
|
|
107
113
|
|
|
108
114
|
def enable_bashrc_function(fn: FunctionDef) -> bool:
|
|
109
|
-
|
|
115
|
+
block = render_bashrc_function(fn)
|
|
116
|
+
verbose.log(f"patching ~/.bashrc with devstuff-fn:{fn.key}")
|
|
117
|
+
verbose.block(block)
|
|
118
|
+
return patch_bashrc(f"devstuff-fn:{fn.key}", block)
|
|
110
119
|
|
|
111
120
|
|
|
112
121
|
def disable_bashrc_function(fn: FunctionDef) -> bool:
|
|
@@ -137,13 +146,25 @@ def run_script_function(
|
|
|
137
146
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
|
138
147
|
f.write(content)
|
|
139
148
|
tmp = f.name
|
|
149
|
+
|
|
150
|
+
# `bash -x` at -vv traces every expanded command inside the function to stderr.
|
|
151
|
+
# A function is an opaque blob of someone else's shell until you can see that;
|
|
152
|
+
# `capture` mode gets it too, since the trace lands in the returned text.
|
|
153
|
+
cmd = ["bash", *(["-x"] if verbose.enabled(verbose.TRACE) else []), tmp, *values]
|
|
154
|
+
verbose.command(cmd)
|
|
155
|
+
verbose.trace(f"params: {_describe_params(fn.params, values)}")
|
|
156
|
+
verbose.block(content)
|
|
140
157
|
try:
|
|
141
158
|
if capture:
|
|
142
|
-
proc = subprocess.run(
|
|
143
|
-
["bash", tmp, *values], check=True, capture_output=True, text=True
|
|
144
|
-
)
|
|
159
|
+
proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
145
160
|
return ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
146
|
-
subprocess.run(
|
|
161
|
+
subprocess.run(cmd, check=True)
|
|
147
162
|
return None
|
|
148
163
|
finally:
|
|
149
164
|
os.unlink(tmp)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _describe_params(params: list[FunctionParam], values: list[str]) -> str:
|
|
168
|
+
if not params:
|
|
169
|
+
return "(none)"
|
|
170
|
+
return " ".join(f"{p.name}={v!r}" for p, v in zip(params, values, strict=True))
|
dev_setup/functions.yaml
CHANGED
|
@@ -88,6 +88,135 @@ functions:
|
|
|
88
88
|
pi -p "/dogfood $url $instruction"
|
|
89
89
|
docs_url: https://pi.dev/docs/latest
|
|
90
90
|
|
|
91
|
+
whats-on-port:
|
|
92
|
+
name: What's On This Port
|
|
93
|
+
description: Find which process is listening on a port (and whether you can see it)
|
|
94
|
+
category: network
|
|
95
|
+
type: script
|
|
96
|
+
params:
|
|
97
|
+
- name: port
|
|
98
|
+
description: Port number to look up (1-65535)
|
|
99
|
+
required: true
|
|
100
|
+
- name: protocol
|
|
101
|
+
description: tcp, udp, or all (default all)
|
|
102
|
+
required: false
|
|
103
|
+
default: all
|
|
104
|
+
script: |
|
|
105
|
+
# Deliberately no `set -e`: this script's whole job is to tell "in use" from
|
|
106
|
+
# "free" from "cannot tell", which is exit-code work that -e would abort.
|
|
107
|
+
set -uo pipefail
|
|
108
|
+
|
|
109
|
+
case "$port" in
|
|
110
|
+
''|*[!0-9]*)
|
|
111
|
+
echo "Port must be a number between 1 and 65535, got: ${port:-<empty>}" >&2
|
|
112
|
+
exit 2
|
|
113
|
+
;;
|
|
114
|
+
esac
|
|
115
|
+
if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
|
116
|
+
echo "Port must be between 1 and 65535, got: $port" >&2
|
|
117
|
+
exit 2
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
case "${protocol:-all}" in
|
|
121
|
+
tcp) flags="-tlpnH" ;;
|
|
122
|
+
udp) flags="-ulpnH" ;;
|
|
123
|
+
all|"") flags="-tulpnH"; protocol="all" ;;
|
|
124
|
+
*) echo "Protocol must be tcp, udp or all, got: $protocol" >&2; exit 2 ;;
|
|
125
|
+
esac
|
|
126
|
+
|
|
127
|
+
# ss ships in iproute2, which is not in the devstuff catalog and is not on every
|
|
128
|
+
# minimal image (this repo's own CI container has lsof but no ss), so say what to
|
|
129
|
+
# install rather than letting "command not found" surface.
|
|
130
|
+
if ! command -v ss >/dev/null 2>&1; then
|
|
131
|
+
echo "ss is required to look up sockets (it is part of iproute2)." >&2
|
|
132
|
+
echo "Install it with: sudo apt-get install -y iproute2" >&2
|
|
133
|
+
exit 1
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# `ss` exits 0 whether or not the filter matched anything, so an empty result is
|
|
137
|
+
# the only signal that the port is free.
|
|
138
|
+
sockets="$(ss $flags "sport = :$port" 2>/dev/null || true)"
|
|
139
|
+
|
|
140
|
+
# Unprivileged `ss` still lists a socket owned by another user but silently drops
|
|
141
|
+
# the users:((...)) field, so the port reads as taken with nothing named. Retry
|
|
142
|
+
# under sudo only when it needs no password — a diagnostic should not stop to ask
|
|
143
|
+
# for one. (lsof and fuser are worse here: both report *nothing* and exit as if
|
|
144
|
+
# the port were free, which is why this uses ss.)
|
|
145
|
+
escalated=""
|
|
146
|
+
if [ -n "$sockets" ] && ! printf '%s' "$sockets" | grep -q 'pid='; then
|
|
147
|
+
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
|
|
148
|
+
sockets="$(sudo -n ss $flags "sport = :$port" 2>/dev/null || printf '%s' "$sockets")"
|
|
149
|
+
escalated="yes"
|
|
150
|
+
fi
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
# "Nothing found" is an answer, not a failure: `devstuff run` flattens every
|
|
154
|
+
# non-zero code to 1, so exiting non-zero here would only buy a scary "command
|
|
155
|
+
# failed" banner under a correct result. Non-zero is kept for the cases where
|
|
156
|
+
# the lookup could not be performed at all (bad input, no ss).
|
|
157
|
+
if [ -z "$sockets" ]; then
|
|
158
|
+
echo "Nothing is listening on port $port ($protocol)."
|
|
159
|
+
echo
|
|
160
|
+
echo "A bind can still fail after this: a container port published without the"
|
|
161
|
+
echo "userland proxy is forwarded by NAT with no host socket to find, and sockets"
|
|
162
|
+
echo "in another network namespace are invisible from here."
|
|
163
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
164
|
+
echo "Sockets you have no permission to see are not listed either — to rule"
|
|
165
|
+
echo "that out: sudo devstuff run whats-on-port $port"
|
|
166
|
+
fi
|
|
167
|
+
exit 0
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
echo "Port $port ($protocol) is in use:"
|
|
171
|
+
echo
|
|
172
|
+
printf '%s\n' "$sockets" | sed 's/^/ /'
|
|
173
|
+
|
|
174
|
+
# One port can have several holders (SO_REUSEPORT), so this reports every PID
|
|
175
|
+
# rather than assuming a single answer.
|
|
176
|
+
pids="$(printf '%s\n' "$sockets" | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -un)"
|
|
177
|
+
if [ -n "$pids" ]; then
|
|
178
|
+
echo
|
|
179
|
+
echo "Holding process(es):"
|
|
180
|
+
printf ' %-8s %-10s %-10s %s\n' PID USER ELAPSED COMMAND
|
|
181
|
+
for pid in $pids; do
|
|
182
|
+
# The full command line is never truncated: it is usually the thing that
|
|
183
|
+
# identifies which of five node processes this actually is.
|
|
184
|
+
ps -o pid=,user=,etime=,args= -p "$pid" 2>/dev/null \
|
|
185
|
+
| awk '{printf " %-8s %-10s %-10s ", $1, $2, $3; $1=$2=$3=""; sub(/^ +/, ""); print}'
|
|
186
|
+
done
|
|
187
|
+
if [ -n "$escalated" ]; then
|
|
188
|
+
echo
|
|
189
|
+
echo " (the process details above needed root — re-read via sudo)"
|
|
190
|
+
fi
|
|
191
|
+
else
|
|
192
|
+
echo
|
|
193
|
+
echo "The socket exists, but its owning process belongs to another user and is"
|
|
194
|
+
echo "not visible from here. Re-run as root to name it:"
|
|
195
|
+
echo " sudo devstuff run whats-on-port $port"
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
# Counted on the listener's side only: matching both sport and dport counts each
|
|
199
|
+
# connection twice, once per end of a loopback pair.
|
|
200
|
+
conns="$(ss -tnpH "sport = :$port" 2>/dev/null | grep -c ESTAB || true)"
|
|
201
|
+
if [ "${conns:-0}" -gt 0 ]; then
|
|
202
|
+
echo
|
|
203
|
+
echo "$conns established connection(s) to this port — list them with:"
|
|
204
|
+
echo " ss -tnp 'sport = :$port'"
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
# Best-effort: a published container port is held by docker-proxy, whose command
|
|
208
|
+
# line does not say which container it belongs to. Silent on any failure, so no
|
|
209
|
+
# daemon (or no docker at all) costs nothing.
|
|
210
|
+
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
211
|
+
containers="$(docker ps --format '{{.Names}}\t{{.Ports}}' 2>/dev/null | grep -E ":$port->" || true)"
|
|
212
|
+
if [ -n "$containers" ]; then
|
|
213
|
+
echo
|
|
214
|
+
echo "Published by container(s):"
|
|
215
|
+
printf '%s\n' "$containers" | sed 's/^/ /'
|
|
216
|
+
fi
|
|
217
|
+
fi
|
|
218
|
+
docs_url: https://man7.org/linux/man-pages/man8/ss.8.html
|
|
219
|
+
|
|
91
220
|
aws-saml-reauth:
|
|
92
221
|
name: AWS SAML Reauth
|
|
93
222
|
description: Reauthorize the AWS CLI via saml2aws
|
dev_setup/generic.py
CHANGED
|
@@ -10,10 +10,9 @@ from collections.abc import Callable
|
|
|
10
10
|
from dataclasses import dataclass, fields
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
+
from dev_setup import verbose
|
|
13
14
|
from dev_setup.base import Tool
|
|
14
15
|
|
|
15
|
-
_verbose: bool = False
|
|
16
|
-
|
|
17
16
|
# Auto-inferred requires per install type (re-derived on load, not persisted)
|
|
18
17
|
AUTO_REQUIRES = {
|
|
19
18
|
"npm": ["nvm"],
|
|
@@ -43,9 +42,20 @@ class UpdateStatus:
|
|
|
43
42
|
|
|
44
43
|
|
|
45
44
|
def _run(cmd: list, *, cwd: Path | None = None) -> None:
|
|
46
|
-
"""Run a command.
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
"""Run a state-changing command (install/remove/update). Raises RuntimeError on failure.
|
|
46
|
+
|
|
47
|
+
Streams output when verbose, captures when not — captured output is discarded on
|
|
48
|
+
success and surfaced as the exception message on failure, which is the only reason
|
|
49
|
+
quiet mode can say anything useful about what went wrong.
|
|
50
|
+
"""
|
|
51
|
+
verbose.command(cmd, cwd=cwd)
|
|
52
|
+
if verbose.enabled():
|
|
53
|
+
try:
|
|
54
|
+
subprocess.run(cmd, check=True, cwd=cwd)
|
|
55
|
+
except subprocess.CalledProcessError as e:
|
|
56
|
+
# The output already streamed past; repeating the whole argv in the error
|
|
57
|
+
# adds nothing the user can't see directly above it.
|
|
58
|
+
raise RuntimeError(f"exit code {e.returncode}") from e
|
|
49
59
|
else:
|
|
50
60
|
try:
|
|
51
61
|
subprocess.run(cmd, check=True, capture_output=True, text=True, cwd=cwd)
|
|
@@ -54,6 +64,26 @@ def _run(cmd: list, *, cwd: Path | None = None) -> None:
|
|
|
54
64
|
raise RuntimeError(msg) from e
|
|
55
65
|
|
|
56
66
|
|
|
67
|
+
def _probe(cmd: list, *, log_at: int = verbose.TRACE, **kwargs) -> subprocess.CompletedProcess:
|
|
68
|
+
"""Run a command whose output we capture rather than stream, and don't raise on.
|
|
69
|
+
|
|
70
|
+
Used for read-only probes (version checks, dpkg queries, …) and for the few
|
|
71
|
+
best-effort actions whose failure is deliberately ignored. Verbosity only adds
|
|
72
|
+
logging here, and by default only at -vv: probes fire constantly — one or more per
|
|
73
|
+
tool on every `list` — and would bury the actual work at -v. `log_at=verbose.VERBOSE`
|
|
74
|
+
marks the ones that are real actions the user should see at -v.
|
|
75
|
+
"""
|
|
76
|
+
kwargs.setdefault("capture_output", True)
|
|
77
|
+
verbose.command(cmd, cwd=kwargs.get("cwd"), minimum=log_at)
|
|
78
|
+
proc = subprocess.run(cmd, **kwargs)
|
|
79
|
+
if verbose.enabled(verbose.TRACE):
|
|
80
|
+
out = "".join(
|
|
81
|
+
part for part in (proc.stdout, proc.stderr) if isinstance(part, str)
|
|
82
|
+
)
|
|
83
|
+
verbose.result(proc.returncode, out)
|
|
84
|
+
return proc
|
|
85
|
+
|
|
86
|
+
|
|
57
87
|
@dataclass
|
|
58
88
|
class GenericTool(Tool):
|
|
59
89
|
key: str = ""
|
|
@@ -160,7 +190,7 @@ class GenericTool(Tool):
|
|
|
160
190
|
if cmd and shutil.which(cmd):
|
|
161
191
|
for flag in ["--version", "-v", "version"]:
|
|
162
192
|
try:
|
|
163
|
-
r =
|
|
193
|
+
r = _probe([cmd, flag], capture_output=True, text=True, timeout=5)
|
|
164
194
|
if r.returncode == 0 and r.stdout.strip():
|
|
165
195
|
return r.stdout.strip().splitlines()[0]
|
|
166
196
|
except Exception:
|
|
@@ -178,18 +208,13 @@ class GenericTool(Tool):
|
|
|
178
208
|
|
|
179
209
|
|
|
180
210
|
def _install_npm(tool: GenericTool) -> None:
|
|
181
|
-
from dev_setup import ui
|
|
182
211
|
if not tool.npm_name:
|
|
183
212
|
raise RuntimeError("npm_name not set")
|
|
184
|
-
with
|
|
185
|
-
|
|
186
|
-
["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(tool.npm_name)}"],
|
|
187
|
-
check=True, capture_output=True,
|
|
188
|
-
)
|
|
213
|
+
with verbose.step(f"Installing {tool.name} via npm..."):
|
|
214
|
+
_run(["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(tool.npm_name)}"])
|
|
189
215
|
|
|
190
216
|
|
|
191
217
|
def _install_uvx(tool: GenericTool) -> None:
|
|
192
|
-
from dev_setup import ui
|
|
193
218
|
if not tool.pip_name:
|
|
194
219
|
raise RuntimeError("pip_name not set")
|
|
195
220
|
uv = shutil.which("uv")
|
|
@@ -198,26 +223,19 @@ def _install_uvx(tool: GenericTool) -> None:
|
|
|
198
223
|
"uv is required to install uvx packages. "
|
|
199
224
|
"Install it first: devstuff install uv"
|
|
200
225
|
)
|
|
201
|
-
with
|
|
202
|
-
|
|
226
|
+
with verbose.step(f"Installing {tool.name} via uvx..."):
|
|
227
|
+
_run([uv, "tool", "install", tool.pip_name])
|
|
203
228
|
|
|
204
229
|
|
|
205
230
|
def _install_git(tool: GenericTool) -> None:
|
|
206
|
-
from dev_setup import ui
|
|
207
231
|
if not tool.git_url:
|
|
208
232
|
raise RuntimeError("git_url not set")
|
|
209
233
|
dest = _git_clone_dest(tool.git_url)
|
|
210
|
-
with
|
|
211
|
-
|
|
212
|
-
["git", "clone", "--depth=1", tool.git_url, str(dest)],
|
|
213
|
-
check=True, capture_output=True,
|
|
214
|
-
)
|
|
234
|
+
with verbose.step(f"Cloning {tool.name}..."):
|
|
235
|
+
_run(["git", "clone", "--depth=1", tool.git_url, str(dest)])
|
|
215
236
|
if tool.git_install_cmd:
|
|
216
|
-
with
|
|
217
|
-
|
|
218
|
-
["bash", "-c", tool.git_install_cmd],
|
|
219
|
-
cwd=dest, check=True, capture_output=True,
|
|
220
|
-
)
|
|
237
|
+
with verbose.step("Running install command..."):
|
|
238
|
+
_run(["bash", "-c", tool.git_install_cmd], cwd=dest)
|
|
221
239
|
|
|
222
240
|
|
|
223
241
|
def _install_apt(tool: GenericTool) -> None:
|
|
@@ -225,7 +243,7 @@ def _install_apt(tool: GenericTool) -> None:
|
|
|
225
243
|
if not tool.apt_packages:
|
|
226
244
|
raise RuntimeError("apt_packages not set")
|
|
227
245
|
ui.info(f"Installing {tool.name} via apt...")
|
|
228
|
-
|
|
246
|
+
_run_apt_update()
|
|
229
247
|
_run(["sudo", "apt-get", "install", "-y"] + tool.apt_packages.split())
|
|
230
248
|
|
|
231
249
|
|
|
@@ -250,19 +268,14 @@ def _install_bash(tool: GenericTool) -> None:
|
|
|
250
268
|
|
|
251
269
|
|
|
252
270
|
def _update_npm(tool: GenericTool, version: str | None) -> None:
|
|
253
|
-
from dev_setup import ui
|
|
254
271
|
if not tool.npm_name:
|
|
255
272
|
raise RuntimeError("npm_name not set")
|
|
256
273
|
target = f"{tool.npm_name}@{version or 'latest'}"
|
|
257
|
-
with
|
|
258
|
-
|
|
259
|
-
["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(target)}"],
|
|
260
|
-
check=True, capture_output=True,
|
|
261
|
-
)
|
|
274
|
+
with verbose.step(f"Updating {tool.name} via npm..."):
|
|
275
|
+
_run(["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(target)}"])
|
|
262
276
|
|
|
263
277
|
|
|
264
278
|
def _update_uvx(tool: GenericTool, version: str | None) -> None:
|
|
265
|
-
from dev_setup import ui
|
|
266
279
|
if not tool.pip_name:
|
|
267
280
|
raise RuntimeError("pip_name not set")
|
|
268
281
|
uv = shutil.which("uv")
|
|
@@ -272,8 +285,8 @@ def _update_uvx(tool: GenericTool, version: str | None) -> None:
|
|
|
272
285
|
"Install it first: devstuff install uv"
|
|
273
286
|
)
|
|
274
287
|
target = f"{tool.pip_name}=={version}" if version else tool.pip_name
|
|
275
|
-
with
|
|
276
|
-
|
|
288
|
+
with verbose.step(f"Updating {tool.name} via uv tool upgrade..."):
|
|
289
|
+
_run([uv, "tool", "upgrade", target])
|
|
277
290
|
|
|
278
291
|
|
|
279
292
|
def _update_apt(tool: GenericTool, version: str | None) -> None:
|
|
@@ -281,7 +294,7 @@ def _update_apt(tool: GenericTool, version: str | None) -> None:
|
|
|
281
294
|
if not tool.apt_packages:
|
|
282
295
|
raise RuntimeError("apt_packages not set")
|
|
283
296
|
packages = tool.apt_packages.split()
|
|
284
|
-
|
|
297
|
+
_run_apt_update()
|
|
285
298
|
if version:
|
|
286
299
|
if len(packages) != 1:
|
|
287
300
|
raise RuntimeError(
|
|
@@ -295,7 +308,6 @@ def _update_apt(tool: GenericTool, version: str | None) -> None:
|
|
|
295
308
|
|
|
296
309
|
|
|
297
310
|
def _update_git(tool: GenericTool, version: str | None) -> None:
|
|
298
|
-
from dev_setup import ui
|
|
299
311
|
if version:
|
|
300
312
|
raise RuntimeError(
|
|
301
313
|
"Version pinning is not supported for git tools (shallow clone). "
|
|
@@ -306,13 +318,10 @@ def _update_git(tool: GenericTool, version: str | None) -> None:
|
|
|
306
318
|
dest = _git_clone_dest(tool.git_url)
|
|
307
319
|
if not dest.exists():
|
|
308
320
|
raise RuntimeError(f"{tool.name} clone not found at {dest}")
|
|
309
|
-
with
|
|
321
|
+
with verbose.step(f"Pulling latest {tool.name}..."):
|
|
310
322
|
_run(["git", "-C", str(dest), "pull"])
|
|
311
323
|
if tool.git_install_cmd:
|
|
312
|
-
|
|
313
|
-
["bash", "-c", tool.git_install_cmd],
|
|
314
|
-
cwd=dest, check=True, capture_output=True,
|
|
315
|
-
)
|
|
324
|
+
_run(["bash", "-c", tool.git_install_cmd], cwd=dest)
|
|
316
325
|
|
|
317
326
|
|
|
318
327
|
def _update_script_url(tool: GenericTool, version: str | None) -> None:
|
|
@@ -335,7 +344,7 @@ def _update_bash(tool: GenericTool, version: str | None) -> None:
|
|
|
335
344
|
def _npm_installed_version(pkg: str) -> str:
|
|
336
345
|
import json
|
|
337
346
|
try:
|
|
338
|
-
r =
|
|
347
|
+
r = _probe(
|
|
339
348
|
["bash", "-lc", f"{_npm_init()} && npm list -g --depth=0 --json {shlex.quote(pkg)}"],
|
|
340
349
|
capture_output=True, text=True, timeout=10,
|
|
341
350
|
)
|
|
@@ -347,7 +356,7 @@ def _npm_installed_version(pkg: str) -> str:
|
|
|
347
356
|
|
|
348
357
|
def _npm_latest_version(pkg: str) -> str:
|
|
349
358
|
try:
|
|
350
|
-
r =
|
|
359
|
+
r = _probe(
|
|
351
360
|
["bash", "-lc", f"{_npm_init()} && npm view {shlex.quote(pkg)} version"],
|
|
352
361
|
capture_output=True, text=True, timeout=10,
|
|
353
362
|
)
|
|
@@ -371,7 +380,7 @@ def _uv_tool_current_version(pkg: str) -> str:
|
|
|
371
380
|
if not uv:
|
|
372
381
|
return ""
|
|
373
382
|
try:
|
|
374
|
-
r =
|
|
383
|
+
r = _probe(
|
|
375
384
|
[uv, "tool", "list", "--color", "never"], capture_output=True, text=True, timeout=15,
|
|
376
385
|
)
|
|
377
386
|
except Exception:
|
|
@@ -392,7 +401,7 @@ def _uv_outdated_map() -> dict[str, str]:
|
|
|
392
401
|
if not uv:
|
|
393
402
|
return {}
|
|
394
403
|
try:
|
|
395
|
-
r =
|
|
404
|
+
r = _probe(
|
|
396
405
|
[uv, "tool", "list", "--outdated", "--color", "never"],
|
|
397
406
|
capture_output=True, text=True, timeout=20,
|
|
398
407
|
)
|
|
@@ -423,14 +432,14 @@ def _check_update_apt(tool: GenericTool) -> UpdateStatus:
|
|
|
423
432
|
return UpdateStatus()
|
|
424
433
|
pkg = tool.apt_packages.split()[0]
|
|
425
434
|
try:
|
|
426
|
-
r =
|
|
435
|
+
r = _probe(
|
|
427
436
|
["dpkg-query", "-W", "-f=${Version}", pkg], capture_output=True, text=True, timeout=10,
|
|
428
437
|
)
|
|
429
438
|
current = r.stdout.strip() if r.returncode == 0 else ""
|
|
430
439
|
except Exception:
|
|
431
440
|
current = ""
|
|
432
441
|
try:
|
|
433
|
-
r =
|
|
442
|
+
r = _probe(["apt-cache", "policy", pkg], capture_output=True, text=True, timeout=10)
|
|
434
443
|
except Exception:
|
|
435
444
|
return UpdateStatus(current=current)
|
|
436
445
|
candidate = ""
|
|
@@ -451,7 +460,7 @@ def _check_update_git(tool: GenericTool) -> UpdateStatus:
|
|
|
451
460
|
if not dest.exists():
|
|
452
461
|
return UpdateStatus()
|
|
453
462
|
try:
|
|
454
|
-
r =
|
|
463
|
+
r = _probe(
|
|
455
464
|
["git", "-C", str(dest), "rev-parse", "--short", "HEAD"],
|
|
456
465
|
capture_output=True, text=True, timeout=10,
|
|
457
466
|
)
|
|
@@ -459,7 +468,7 @@ def _check_update_git(tool: GenericTool) -> UpdateStatus:
|
|
|
459
468
|
except Exception:
|
|
460
469
|
current = ""
|
|
461
470
|
try:
|
|
462
|
-
r =
|
|
471
|
+
r = _probe(
|
|
463
472
|
["git", "ls-remote", tool.git_url, "HEAD"], capture_output=True, text=True, timeout=10,
|
|
464
473
|
)
|
|
465
474
|
remote_full = r.stdout.split()[0] if r.returncode == 0 and r.stdout.strip() else ""
|
|
@@ -486,32 +495,28 @@ _UPDATE_CHECKERS: dict[str, Callable[[GenericTool], UpdateStatus]] = {
|
|
|
486
495
|
|
|
487
496
|
|
|
488
497
|
def _remove_npm(tool: GenericTool) -> None:
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
subprocess.run(
|
|
492
|
-
["bash", "-lc", f"{_npm_init()} && npm uninstall -g {shlex.quote(tool.npm_name)}"],
|
|
493
|
-
check=True, capture_output=True,
|
|
494
|
-
)
|
|
498
|
+
with verbose.step(f"Removing {tool.name}..."):
|
|
499
|
+
_run(["bash", "-lc", f"{_npm_init()} && npm uninstall -g {shlex.quote(tool.npm_name)}"])
|
|
495
500
|
|
|
496
501
|
|
|
497
502
|
def _remove_uvx(tool: GenericTool) -> None:
|
|
498
|
-
from dev_setup import ui
|
|
499
503
|
uv = shutil.which("uv")
|
|
500
504
|
if not uv:
|
|
501
505
|
raise RuntimeError(
|
|
502
506
|
"uv is required to remove uvx packages. "
|
|
503
507
|
"Install it first: devstuff install uv"
|
|
504
508
|
)
|
|
505
|
-
with
|
|
506
|
-
|
|
509
|
+
with verbose.step(f"Removing {tool.name}..."):
|
|
510
|
+
_run([uv, "tool", "uninstall", tool.pip_name])
|
|
507
511
|
|
|
508
512
|
|
|
509
513
|
def _remove_git(tool: GenericTool) -> None:
|
|
510
|
-
from dev_setup import ui
|
|
511
514
|
dest = _git_clone_dest(tool.git_url)
|
|
512
515
|
if tool.git_remove_cmd:
|
|
513
|
-
|
|
514
|
-
|
|
516
|
+
# Best-effort: the clone directory is removed below either way, so a remove
|
|
517
|
+
# command that fails must not abort the removal.
|
|
518
|
+
with verbose.step("Running remove command..."):
|
|
519
|
+
_probe(["bash", "-c", tool.git_remove_cmd], cwd=dest, log_at=verbose.VERBOSE)
|
|
515
520
|
if dest.exists():
|
|
516
521
|
shutil.rmtree(dest)
|
|
517
522
|
|
|
@@ -610,7 +615,7 @@ _UPDATERS: dict[str, Callable[[GenericTool, str | None], None]] = {
|
|
|
610
615
|
|
|
611
616
|
def _npm_global_installed(pkg: str) -> bool:
|
|
612
617
|
try:
|
|
613
|
-
r =
|
|
618
|
+
r = _probe(
|
|
614
619
|
["bash", "-lc", f"{_npm_init()} && npm list -g --depth=0 {shlex.quote(pkg)}"],
|
|
615
620
|
capture_output=True,
|
|
616
621
|
text=True,
|
|
@@ -620,13 +625,19 @@ def _npm_global_installed(pkg: str) -> bool:
|
|
|
620
625
|
return False
|
|
621
626
|
|
|
622
627
|
|
|
628
|
+
def _run_apt_update() -> None:
|
|
629
|
+
"""Refresh apt's package lists. Deliberately non-fatal: a failing mirror shouldn't
|
|
630
|
+
stop an install of a package that may already be cached."""
|
|
631
|
+
_probe(["sudo", "apt-get", "update", "-q"], log_at=verbose.VERBOSE)
|
|
632
|
+
|
|
633
|
+
|
|
623
634
|
def _npm_init() -> str:
|
|
624
635
|
return '. "$HOME/.nvm/nvm.sh" 2>/dev/null || true'
|
|
625
636
|
|
|
626
637
|
|
|
627
638
|
def _apt_installed(pkg: str) -> bool:
|
|
628
639
|
try:
|
|
629
|
-
r =
|
|
640
|
+
r = _probe(["dpkg", "-s", pkg], capture_output=True, text=True)
|
|
630
641
|
return "Status: install ok installed" in r.stdout
|
|
631
642
|
except Exception:
|
|
632
643
|
return False
|
|
@@ -641,8 +652,10 @@ def _download_script(url: str, *, expected_sha256: str = "") -> str:
|
|
|
641
652
|
"""Download a script over HTTPS and optionally verify its sha256."""
|
|
642
653
|
import urllib.request
|
|
643
654
|
|
|
655
|
+
verbose.log(f"downloading {url}")
|
|
644
656
|
with urllib.request.urlopen(url, timeout=30) as resp:
|
|
645
657
|
data = resp.read()
|
|
658
|
+
verbose.trace(f"downloaded {len(data)} bytes, sha256 {hashlib.sha256(data).hexdigest()}")
|
|
646
659
|
|
|
647
660
|
if expected_sha256:
|
|
648
661
|
actual = hashlib.sha256(data).hexdigest()
|
|
@@ -664,8 +677,12 @@ def _run_bash_script(script: str) -> None:
|
|
|
664
677
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
|
665
678
|
f.write(script)
|
|
666
679
|
tmp = f.name
|
|
680
|
+
verbose.trace(f"script → {tmp}")
|
|
681
|
+
verbose.block(script)
|
|
667
682
|
try:
|
|
668
|
-
|
|
683
|
+
# -x at -vv traces each expanded command to stderr as the script runs, which is
|
|
684
|
+
# the only way to see where a downloaded installer actually failed.
|
|
685
|
+
_run(["bash", *(["-x"] if verbose.enabled(verbose.TRACE) else []), tmp])
|
|
669
686
|
finally:
|
|
670
687
|
os.unlink(tmp)
|
|
671
688
|
|
|
@@ -686,7 +703,7 @@ def _is_simple_command(cmd: str) -> bool:
|
|
|
686
703
|
def _bash_version(key: str) -> str:
|
|
687
704
|
for flag in ["--version", "-v", "version"]:
|
|
688
705
|
try:
|
|
689
|
-
r =
|
|
706
|
+
r = _probe(
|
|
690
707
|
[
|
|
691
708
|
"bash", "-lc",
|
|
692
709
|
f'. "$HOME/.nvm/nvm.sh" 2>/dev/null; {shlex.quote(key)} {flag} 2>/dev/null',
|
|
@@ -707,13 +724,13 @@ def _check_cmd_installed(cmd: str, *, install_type: str = "") -> bool:
|
|
|
707
724
|
# Only source nvm for npm-type tools — avoids unnecessary shell overhead
|
|
708
725
|
prefix = f"{_npm_init()} && " if install_type == "npm" else ""
|
|
709
726
|
try:
|
|
710
|
-
return
|
|
727
|
+
return _probe(
|
|
711
728
|
["bash", "-lc", f"{prefix}command -v {cmd} >/dev/null 2>&1"],
|
|
712
729
|
capture_output=True,
|
|
713
730
|
).returncode == 0
|
|
714
731
|
except Exception:
|
|
715
732
|
return False
|
|
716
733
|
try:
|
|
717
|
-
return
|
|
734
|
+
return _probe(["bash", "-lc", cmd], capture_output=True).returncode == 0
|
|
718
735
|
except Exception:
|
|
719
736
|
return False
|
dev_setup/verbose.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Process-wide verbosity level and the logging helpers built on it.
|
|
2
|
+
|
|
3
|
+
Three levels, set once per process by the `-v` CLI option (`verbose.option`) or the
|
|
4
|
+
`DEVSTUFF_VERBOSE` environment variable:
|
|
5
|
+
|
|
6
|
+
0 quiet — normal output only; subprocess output is captured and shown only on failure
|
|
7
|
+
1 -v — every command devstuff runs is logged, and its output streams live
|
|
8
|
+
2 -vv — plus read-only probes, exit codes, captured output, and `bash -x` tracing
|
|
9
|
+
of script bodies (functions, install/remove scripts)
|
|
10
|
+
|
|
11
|
+
**Everything here writes to stderr, never stdout.** `devstuff run <key>` in
|
|
12
|
+
`register: eval` mode prints shell code to stdout for `eval "$(...)"` to consume, so a
|
|
13
|
+
verbose line on stdout would be executed in the user's shell. Routing the whole logger
|
|
14
|
+
to stderr makes that impossible by construction rather than by remembering to check the
|
|
15
|
+
level at each call site.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
from collections.abc import Generator
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
# stderr, and no highlighting/markup: these lines carry raw command lines and script
|
|
28
|
+
# bodies, which are full of characters rich would otherwise style or eat.
|
|
29
|
+
_console = Console(stderr=True, highlight=False)
|
|
30
|
+
|
|
31
|
+
# soft_wrap keeps a logged command line or script line on one terminal line. Rich's
|
|
32
|
+
# default word-wrap breaks a long command mid-word, and what the user most wants to do
|
|
33
|
+
# with a verbose line — copy it back into a shell — stops working when it does.
|
|
34
|
+
_PRINT = {"style": "dim", "markup": False, "soft_wrap": True}
|
|
35
|
+
|
|
36
|
+
QUIET = 0
|
|
37
|
+
VERBOSE = 1
|
|
38
|
+
TRACE = 2
|
|
39
|
+
|
|
40
|
+
_level: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _from_env() -> int:
|
|
44
|
+
"""DEVSTUFF_VERBOSE=1|2 (or a bare word like "true") as a starting level.
|
|
45
|
+
|
|
46
|
+
Lets a function invoked from a script or another tool be verbose without the caller
|
|
47
|
+
threading a flag through; an explicit -v still wins because it can only raise it.
|
|
48
|
+
"""
|
|
49
|
+
raw = os.environ.get("DEVSTUFF_VERBOSE", "").strip().lower()
|
|
50
|
+
if not raw:
|
|
51
|
+
return QUIET
|
|
52
|
+
if raw.isdigit():
|
|
53
|
+
return min(int(raw), TRACE)
|
|
54
|
+
return VERBOSE if raw in ("true", "yes", "on", "y") else QUIET
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_level = _from_env()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_level(value: int) -> None:
|
|
61
|
+
global _level
|
|
62
|
+
_level = max(QUIET, min(TRACE, value))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def level() -> int:
|
|
66
|
+
return _level
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def enabled(minimum: int = VERBOSE) -> bool:
|
|
70
|
+
return _level >= minimum
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def log(msg: str, *, minimum: int = VERBOSE) -> None:
|
|
74
|
+
"""A verbose note. Dim, stderr, one line."""
|
|
75
|
+
if _level >= minimum:
|
|
76
|
+
_console.print(f" {msg}", **_PRINT)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def trace(msg: str) -> None:
|
|
80
|
+
"""A note only worth showing at -vv."""
|
|
81
|
+
log(msg, minimum=TRACE)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def command(cmd: list[str] | str, *, cwd: object = None, minimum: int = VERBOSE) -> None:
|
|
85
|
+
"""Log a command about to run, in a form the user could paste back into a shell."""
|
|
86
|
+
if _level < minimum:
|
|
87
|
+
return
|
|
88
|
+
text = cmd if isinstance(cmd, str) else shlex.join(str(c) for c in cmd)
|
|
89
|
+
log(f"$ {text}" + (f" (in {cwd})" if cwd else ""), minimum=minimum)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def result(returncode: int, output: str = "", *, minimum: int = TRACE) -> None:
|
|
93
|
+
"""Log a captured command's exit code and output."""
|
|
94
|
+
if _level < minimum:
|
|
95
|
+
return
|
|
96
|
+
log(f"→ exit {returncode}", minimum=minimum)
|
|
97
|
+
block(output, minimum=minimum)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def block(text: str, *, minimum: int = TRACE) -> None:
|
|
101
|
+
"""Log a multi-line body (captured output, a script) indented under the last line."""
|
|
102
|
+
if _level < minimum or not text.strip():
|
|
103
|
+
return
|
|
104
|
+
for line in text.rstrip("\n").splitlines():
|
|
105
|
+
_console.print(f" │ {line}", **_PRINT)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def step(label: str) -> Generator[None, None, None]:
|
|
110
|
+
"""A unit of work: a spinner normally, a logged line when verbose.
|
|
111
|
+
|
|
112
|
+
The spinner has to go when verbose — it repaints its own line, so it would fight
|
|
113
|
+
with the subprocess output now streaming to the same terminal.
|
|
114
|
+
"""
|
|
115
|
+
if enabled():
|
|
116
|
+
log(label)
|
|
117
|
+
yield
|
|
118
|
+
else:
|
|
119
|
+
from dev_setup import ui
|
|
120
|
+
with ui.spinner(label):
|
|
121
|
+
yield
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def option(f):
|
|
125
|
+
"""`-v`/`-vv` for a Click command or group, setting the process-wide level.
|
|
126
|
+
|
|
127
|
+
`expose_value=False` — the level is process state read by the subprocess helpers
|
|
128
|
+
deep in `generic.py`/`function_runner.py`, not something each command body threads
|
|
129
|
+
through. Attached to both the `cli` group and individual commands so that
|
|
130
|
+
`devstuff -v install x` and `devstuff install -v x` both work; the callback takes
|
|
131
|
+
the max so the two can't cancel each other out.
|
|
132
|
+
"""
|
|
133
|
+
import click
|
|
134
|
+
|
|
135
|
+
def _callback(ctx: object, param: object, value: int) -> int:
|
|
136
|
+
if value:
|
|
137
|
+
set_level(max(level(), value))
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
return click.option(
|
|
141
|
+
"-v",
|
|
142
|
+
"--verbose",
|
|
143
|
+
count=True,
|
|
144
|
+
expose_value=False,
|
|
145
|
+
is_eager=True,
|
|
146
|
+
callback=_callback,
|
|
147
|
+
help="Verbose output: -v logs and streams every command, -vv adds probes and tracing.",
|
|
148
|
+
)(f)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devstuff
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Development environment setup CLI for Linux
|
|
5
5
|
Project-URL: Repository, https://github.com/thesawdawg/dev-setup-py
|
|
6
6
|
Author-email: Sawyer <sawyerksu@gmail.com>
|
|
@@ -193,6 +193,30 @@ The bash `./devstuff` script in the repo is a convenience runner for the git-clo
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
+
## Verbose output (`-v` / `-vv`)
|
|
197
|
+
|
|
198
|
+
Every command accepts `-v`, before or after the subcommand. devstuff is a thin layer over other
|
|
199
|
+
people's commands; this is how you see them.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
devstuff install lazygit -v # log each command and stream its output live
|
|
203
|
+
devstuff -vv run whats-on-port 8080 # + probes, exit codes, and bash -x line tracing
|
|
204
|
+
DEVSTUFF_VERBOSE=1 devstuff update # same as -v, for scripts and CI
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
| Level | What you get |
|
|
208
|
+
|-------|--------------|
|
|
209
|
+
| *(default)* | Normal output. Command output is captured and shown only if the command fails. |
|
|
210
|
+
| `-v` | Every command devstuff runs, printed as a line you can paste back into a shell, with its output streaming live. Spinners are replaced by plain lines so they don't fight with that output. |
|
|
211
|
+
| `-vv` | Plus read-only probes (version checks, install-state checks) with their exit codes and output, the body of any script before it runs, resolved function parameters, and `bash -x` tracing of every line inside function and installer scripts. |
|
|
212
|
+
|
|
213
|
+
`-vvv` and above are the same as `-vv`.
|
|
214
|
+
|
|
215
|
+
All of it goes to **stderr**, so it never contaminates anything you pipe or capture — including
|
|
216
|
+
`eval "$(devstuff run <shell-eval function>)"`, whose stdout has to be shell code and nothing else.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
196
220
|
## Commands
|
|
197
221
|
|
|
198
222
|
### `list`
|
|
@@ -1105,9 +1129,38 @@ ones tools already have — for now, custom functions are hand-edited YAML at
|
|
|
1105
1129
|
| `ssh-agent-key` | auth | shell-eval (bashrc) | Start ssh-agent in the current shell and add a key to it | `key_path` |
|
|
1106
1130
|
| `validate-docker-compose` | validation | script | Validate a docker-compose.yml file in the current directory | — |
|
|
1107
1131
|
| `validate-yaml` | validation | script | Validate a YAML file's syntax using `yq` | `file` |
|
|
1132
|
+
| `whats-on-port` | network | script | Find which process is listening on a port | `port`, `protocol` (optional: `tcp`/`udp`/`all`) |
|
|
1108
1133
|
| `acc-check` | web-dev | script | Run the pi coding agent's `/dogfood` skill against a web URL | `url`, `instruction` (optional) |
|
|
1109
1134
|
| `aws-saml-reauth` | web-dev | script | Reauthorize the AWS CLI via `saml2aws login --force` | `profile` (optional) |
|
|
1110
1135
|
|
|
1136
|
+
#### `whats-on-port`
|
|
1137
|
+
|
|
1138
|
+
```bash
|
|
1139
|
+
devstuff run whats-on-port 8080 # tcp and udp
|
|
1140
|
+
devstuff run whats-on-port 5432 tcp # one protocol
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
Prints the matching sockets, then the PID, user, elapsed time and **untruncated** command
|
|
1144
|
+
line of every process holding them — plural because `SO_REUSEPORT` lets several processes
|
|
1145
|
+
share one port, and "which of these five node processes" is usually the actual question.
|
|
1146
|
+
|
|
1147
|
+
It uses `ss` rather than `lsof` or `fuser` for one measured reason: **run unprivileged
|
|
1148
|
+
against another user's socket, `lsof` and `fuser` print nothing and exit as though the port
|
|
1149
|
+
were free.** `ss` still lists the socket, just without the `users:((...))` field — so the
|
|
1150
|
+
function can tell "nothing there" apart from "something there I'm not allowed to name", and
|
|
1151
|
+
says which. When the process is hidden it retries under `sudo -n`, and only if that needs no
|
|
1152
|
+
password; a diagnostic shouldn't stop to prompt for one. If sudo isn't available it says
|
|
1153
|
+
what to re-run.
|
|
1154
|
+
|
|
1155
|
+
"Nothing is listening" is reported as a success, not a failure, along with the two reasons a
|
|
1156
|
+
bind can still fail afterwards: a container port published with `userland-proxy: false` is
|
|
1157
|
+
NAT-forwarded with no host socket to find at all, and sockets in another network namespace
|
|
1158
|
+
are invisible. If Docker is running, published container ports are matched and named too —
|
|
1159
|
+
`docker-proxy`'s own command line doesn't say which container it belongs to.
|
|
1160
|
+
|
|
1161
|
+
`ss` comes from `iproute2`, which isn't a devstuff package and isn't on every minimal image,
|
|
1162
|
+
so a missing `ss` names the apt package instead of surfacing "command not found".
|
|
1163
|
+
|
|
1111
1164
|
---
|
|
1112
1165
|
|
|
1113
1166
|
## Built-in packages
|
|
@@ -4,17 +4,18 @@ dev_setup/agent_tools.schema.json,sha256=veXnih00xW4w9yK3ruWz-npUrCnJmcQpQdFvfcu
|
|
|
4
4
|
dev_setup/agent_tools.yaml,sha256=56E70B1W7hBqjGhYhhCAGzFQvi1nUZ04cnNiqZlMjyY,4506
|
|
5
5
|
dev_setup/base.py,sha256=j2EQ5gzeGgtveWQN_BgbLcZianMENJ7PQy8EQ9J9MPE,1946
|
|
6
6
|
dev_setup/catalog.py,sha256=YST_dpTpIh08aLz1FBziywtKX_zFfhlU5fr56hgf0mA,5514
|
|
7
|
-
dev_setup/cli.py,sha256=
|
|
7
|
+
dev_setup/cli.py,sha256=Y_RbHkexTAIQN4neDQ3buo6vmtTMNdXrsYsmY0X_Om8,2976
|
|
8
8
|
dev_setup/doctor.py,sha256=JliUAgmiW2HVCWHhT76PIXTBekNXXrc0VfDMaOeLBWY,15918
|
|
9
|
-
dev_setup/function_runner.py,sha256=
|
|
9
|
+
dev_setup/function_runner.py,sha256=08F6jvB9-oOyE5ivYtRjnL16JTGsfRnkpvkjSVrJhsM,6957
|
|
10
10
|
dev_setup/functions.schema.json,sha256=VVdpH3xWsdbtqw47S1QqpBcs4O9wd3XZ_s0Q3sGwv0Q,6220
|
|
11
|
-
dev_setup/functions.yaml,sha256=
|
|
11
|
+
dev_setup/functions.yaml,sha256=IZbdIsfRkQEvgYOjEtBMYxD-TRdqKCsoF1siWTHEYtE,9173
|
|
12
12
|
dev_setup/functions_catalog.py,sha256=YgR2hY9HM1iol1AZcF-_j02EXCP9U4P3e_5eWes9KH0,7116
|
|
13
13
|
dev_setup/functions_registry.py,sha256=YSl-VgNXPf0hj4WgReg-pZUj4fAjFlwvC165dgon_BE,3682
|
|
14
|
-
dev_setup/generic.py,sha256=
|
|
14
|
+
dev_setup/generic.py,sha256=6-sUq7NtPlabs9T-2qyNqAMa4E6IKfL93IRTzzMsLp8,25313
|
|
15
15
|
dev_setup/registry.py,sha256=8lrk6EXCCWk1WNeNnCGLTJ-v8rpCs7-TLYtVXgiuxnA,2243
|
|
16
16
|
dev_setup/tools.yaml,sha256=4NQIsMV_x_sUjgafLbuXzHoPpuSd8-QS9XOoQiz9DEk,26730
|
|
17
17
|
dev_setup/ui.py,sha256=Z5nNZV8MN8tAjo1ZWq7-QPZYkPKQKuM7JbmGtiuKTqM,3806
|
|
18
|
+
dev_setup/verbose.py,sha256=N3U9JAYOvFwdd8DT6W7Pqq6RCIUfbcQnPsm0O1TnvQY,5003
|
|
18
19
|
dev_setup/agent/__init__.py,sha256=znrgxUCr_y0Qp-f41S6EaTv-hoTYwoZ9mOMLVge-IJg,226
|
|
19
20
|
dev_setup/agent/approval.py,sha256=R2YnbR1fauGtV7-NW5dH1QvWvNHjR3l23DKRTCiqpEE,3625
|
|
20
21
|
dev_setup/agent/bridges.py,sha256=RX5coPXJUSkyZApxgUZghz_57ibcPrWHVz3D44S6z2Q,4814
|
|
@@ -40,12 +41,12 @@ dev_setup/commands/docs_cmd.py,sha256=1PtpwdFO3LrAxG3HyCLFn5SXVnnwqcJ8K_c9cVd8Ph
|
|
|
40
41
|
dev_setup/commands/doctor_cmd.py,sha256=8HqQ9sB3aVDKer6jbqN_Eq0XKZDXM2o48L6_vL8JAcE,3161
|
|
41
42
|
dev_setup/commands/functions_cmd.py,sha256=D9JRYMRavXbROz0XRGjHRHeFvZY-GKyvRCctJODQDBE,2856
|
|
42
43
|
dev_setup/commands/help_cmd.py,sha256=kHbzMggYVj3hS7JKUiCrBY4VtZ5p71YPoPKQ4Y-OYNU,3301
|
|
43
|
-
dev_setup/commands/install_cmd.py,sha256=
|
|
44
|
+
dev_setup/commands/install_cmd.py,sha256=AGrB0CZPT6hZ0Cnn9AgmlyFkW8F1wZufuXVvwYft_Xo,4610
|
|
44
45
|
dev_setup/commands/list_cmd.py,sha256=-BAywXxpSm4-VAlysLzlDwIqvmk_zBKhmzEObM8jigo,2847
|
|
45
|
-
dev_setup/commands/remove_cmd.py,sha256=
|
|
46
|
-
dev_setup/commands/run_cmd.py,sha256=
|
|
46
|
+
dev_setup/commands/remove_cmd.py,sha256=_KqCa2S9fQFngLUs_OpBzjkIkFKgDtKup4gQ5TqLrVI,1239
|
|
47
|
+
dev_setup/commands/run_cmd.py,sha256=e1xfWt09UALiAe03AxzdvZyYpj0V7WKk2-E_nbk9hIw,2933
|
|
47
48
|
dev_setup/commands/skills_cmd.py,sha256=j4rleYEW4lt-O2ssDwGyhwmk2Wiip64C4Z7iPT6c3y0,7482
|
|
48
|
-
dev_setup/commands/update_cmd.py,sha256=
|
|
49
|
+
dev_setup/commands/update_cmd.py,sha256=9Bdgu9wMdliawonDOsfDsjP2am-okuONStICKn9pkkg,4831
|
|
49
50
|
dev_setup/configure/__init__.py,sha256=YXkDx21lDzjEQvypoAyoEAy8srskEzc5t6PR69evPHA,3266
|
|
50
51
|
dev_setup/configure/ansible/__init__.py,sha256=huQL_tfiU1l0WI6nzjTnGZlxCQX2aH7E9n9TrfwmIpU,407
|
|
51
52
|
dev_setup/configure/ansible/detect.py,sha256=5LkdbJg5BU2lrIU4CZm_Lhr6Sm4TJMCh1sMrsRK6rgc,9531
|
|
@@ -89,7 +90,7 @@ dev_setup/configure/starship/model.py,sha256=HNkIj69DqBQFAq0VTHqPLNZT2DuBZSfF-5y
|
|
|
89
90
|
dev_setup/configure/starship/preview.py,sha256=ZaNhNA3AeHHtPnMqrJbB9XVVXDFbH1R36jm8xyYdt2U,6145
|
|
90
91
|
dev_setup/configure/starship/render.py,sha256=gDE0hbGZ9oDCR1VAZQRDm5EFYviMjj0-eSmr1EnZfn8,10262
|
|
91
92
|
dev_setup/configure/starship/wizard.py,sha256=BmWl-U6ttF79gepDhCv8UxT8ZDIMe1SWnhSkA1iIrGQ,15498
|
|
92
|
-
devstuff-2.
|
|
93
|
-
devstuff-2.
|
|
94
|
-
devstuff-2.
|
|
95
|
-
devstuff-2.
|
|
93
|
+
devstuff-2.2.0.dist-info/METADATA,sha256=Jq_Nx1lYKtqm-nRmPEdjg7R3HOte4oGhj9nL4ARUTMk,68408
|
|
94
|
+
devstuff-2.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
95
|
+
devstuff-2.2.0.dist-info/entry_points.txt,sha256=48ITy9ENdXDFrTHwAyVPUpq1MDmgFqcOisOo5mAR6OQ,53
|
|
96
|
+
devstuff-2.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|