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.
@@ -0,0 +1,149 @@
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
+ from dev_setup.generic import UpdateStatus
12
+
13
+
14
+ @click.command("update")
15
+ @click.option("--verbose", "-v", is_flag=True, help="Stream update output to the terminal.")
16
+ @click.option(
17
+ "--version", "target_version", default=None,
18
+ help="Update to a specific version instead of latest. Only valid with a single package.",
19
+ )
20
+ @click.argument("packages", nargs=-1)
21
+ def update_cmd(packages: tuple[str, ...], verbose: bool, target_version: str | None) -> None:
22
+ """Update packages. Interactive picker with recommended updates when called with no arguments."""
23
+ from dev_setup import generic
24
+ generic._verbose = verbose
25
+
26
+ if not packages:
27
+ if target_version:
28
+ ui.error("--version requires a package key.")
29
+ sys.exit(1)
30
+ _update_interactive()
31
+ return
32
+
33
+ if target_version and len(packages) > 1:
34
+ ui.error("--version can only be used with a single package.")
35
+ sys.exit(1)
36
+
37
+ failed = False
38
+ for key in packages:
39
+ if not registry.exists(key):
40
+ ui.error(f"Unknown package: '{key}'")
41
+ failed = True
42
+ continue
43
+ if not _update_one(registry.get(key), target_version): # type: ignore[arg-type]
44
+ failed = True
45
+
46
+ if failed:
47
+ sys.exit(1)
48
+
49
+
50
+ def _update_one(tool: Tool, version: str | None) -> bool:
51
+ ui.section(f"Update {tool.name}")
52
+
53
+ if not tool.is_installed():
54
+ ui.warn(f"{tool.name} is not installed — nothing to update.")
55
+ ui.dim(f"Install first: devstuff install {tool.key}")
56
+ return True
57
+
58
+ if tool.install_type in ("bash", "script"):
59
+ ui.warn(f"Updating {tool.name} re-runs its full installer (may use sudo).")
60
+ if not ui.confirm(f"Continue updating {tool.name}?", default=False):
61
+ ui.dim("Skipped.")
62
+ return True
63
+
64
+ before = tool.get_version()
65
+ try:
66
+ after = tool.update(version=version) or tool.get_version() # type: ignore[attr-defined]
67
+ if before and after and before == after:
68
+ ui.success(f"{tool.name} already up to date: {after}")
69
+ elif before and after:
70
+ ui.success(f"{tool.name} updated: {before} → {after}")
71
+ elif after:
72
+ ui.success(f"{tool.name} updated: {after}")
73
+ else:
74
+ ui.success(f"{tool.name} updated")
75
+ return True
76
+ except Exception as exc:
77
+ ui.error(f"Failed to update {tool.name}: {exc}")
78
+ return False
79
+
80
+
81
+ def _collect_update_candidates() -> list[tuple[Tool, UpdateStatus]]:
82
+ """Return (tool, UpdateStatus) for every installed tool, probed concurrently.
83
+
84
+ Pure data-gathering, kept free of any UI/prompt code so it can be exercised
85
+ directly without a terminal.
86
+ """
87
+ tools = registry.all_tools()
88
+ with ThreadPoolExecutor(max_workers=8) as pool:
89
+ installed_flags = list(pool.map(lambda t: t.is_installed(), tools))
90
+ installed = [t for t, flag in zip(tools, installed_flags, strict=True) if flag]
91
+ if not installed:
92
+ return []
93
+ with ThreadPoolExecutor(max_workers=8) as pool:
94
+ statuses = list(pool.map(lambda t: t.check_for_update(), installed)) # type: ignore[attr-defined]
95
+ return list(zip(installed, statuses, strict=True))
96
+
97
+
98
+ def _update_interactive() -> None:
99
+ ui.print_banner()
100
+
101
+ with ui.spinner("Checking installed packages for available updates..."):
102
+ candidates = _collect_update_candidates()
103
+
104
+ if not candidates:
105
+ ui.info("No installed packages to update.")
106
+ return
107
+
108
+ key_width = max((len(t.key) for t, _ in candidates), default=12) + 2
109
+
110
+ choices: list = []
111
+ for t, status in sorted(candidates, key=lambda c: c[0].key):
112
+ if status.available is True:
113
+ tag = f"update available: {status.current or '?'} → {status.latest}"
114
+ mark = "⬆ "
115
+ elif status.available is False:
116
+ tag = f"up to date ({status.current or status.latest})"
117
+ mark = " "
118
+ else:
119
+ tag = "unknown — reinstall to check"
120
+ mark = " "
121
+ title = [
122
+ ("class:check", mark),
123
+ ("class:text", f"{t.key:<{key_width}}{tag}"),
124
+ ]
125
+ choices.append(questionary.Choice(title=title, value=t.key, checked=status.available is True))
126
+
127
+ selected = questionary.checkbox(
128
+ "Select packages to update:",
129
+ choices=choices,
130
+ instruction="(Space toggle · Enter confirm · pre-checked items have a known update)",
131
+ style=ui._STYLE,
132
+ ).ask()
133
+
134
+ if not selected:
135
+ ui.info("No packages selected.")
136
+ return
137
+
138
+ if not ui.confirm(f"Update {len(selected)} package(s)?"):
139
+ ui.warn("Aborted.")
140
+ return
141
+
142
+ failed = False
143
+ for key in selected:
144
+ tool = registry.get(key)
145
+ if tool and not _update_one(tool, None):
146
+ failed = True
147
+
148
+ if failed:
149
+ sys.exit(1)
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ import tempfile
7
+ from collections.abc import Callable
8
+
9
+ from dev_setup.base import patch_bashrc, remove_bashrc_block
10
+ from dev_setup.functions_registry import FunctionDef, FunctionParam
11
+
12
+
13
+ class ParamResolutionError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def resolve_params(
18
+ params: list[FunctionParam],
19
+ args: tuple[str, ...],
20
+ *,
21
+ prompt: Callable[[FunctionParam], str] | None = None,
22
+ ) -> list[str]:
23
+ """Resolve one value per declared param, filling any gap via `prompt` or a catalog
24
+ default. When `prompt` is None (the eval codepath, which must keep stdout clean),
25
+ missing required params raise instead of being asked for interactively.
26
+
27
+ A value counts as "provided" only if it's non-empty — an explicitly empty positional
28
+ arg (`dev-setup run key ""`) is treated the same as a missing one for a required
29
+ param, rather than silently running with an empty value.
30
+ """
31
+ positional = list(args[: len(params)])
32
+ positional += [""] * (len(params) - len(positional))
33
+
34
+ values: list[str] = []
35
+ missing: list[str] = []
36
+ for p, value in zip(params, positional, strict=True):
37
+ if not value and prompt is not None:
38
+ value = prompt(p)
39
+ if not value and p.default:
40
+ value = p.default
41
+ if not value and p.required:
42
+ missing.append(p.name)
43
+ values.append(value)
44
+
45
+ if missing:
46
+ raise ParamResolutionError("Missing required parameter(s): " + ", ".join(missing))
47
+ return values
48
+
49
+
50
+ def _positional_prelude(params: list[FunctionParam]) -> str:
51
+ """`name="$1"`-style assignments mapping real argv positions to the script's named vars.
52
+
53
+ Used for `script`-type subprocess runs and bashrc-registered functions, both of which
54
+ receive their values as real shell positional arguments at call time.
55
+ """
56
+ return "\n".join(f'{p.name}="${i + 1}"' for i, p in enumerate(params))
57
+
58
+
59
+ def _literal_prelude(params: list[FunctionParam], values: list[str]) -> str:
60
+ """`name='resolved value'` assignments for eval mode.
61
+
62
+ `eval "$(dev-setup run ...)"` has no argv of its own — the values dev-setup resolved
63
+ from its own CLI args must be baked into the printed text as shell-quoted literals.
64
+ """
65
+ return "\n".join(f"{p.name}={shlex.quote(v)}" for p, v in zip(params, values, strict=True))
66
+
67
+
68
+ def render_eval_script(fn: FunctionDef, args: tuple[str, ...]) -> str:
69
+ """Build the text printed for `register: eval` mode. Never prompts — stdout must stay
70
+ clean for `eval "$(...)"` capture, so missing required params raise instead."""
71
+ values = resolve_params(fn.params, args)
72
+ prelude = _literal_prelude(fn.params, values)
73
+ return f"{prelude}\n{fn.script}" if prelude else fn.script
74
+
75
+
76
+ def _bashrc_prelude(fn: FunctionDef) -> list[str]:
77
+ """Per-param lines for a bashrc-registered function: `name="$N"` plus, for required
78
+ params with no default, a guard that fails loudly if the caller left it blank.
79
+
80
+ dev-setup itself is never in the loop when an enabled function is called directly by
81
+ the user's shell — `resolve_params` can't help here — so this is the only place
82
+ "required" can be enforced for this mode.
83
+ """
84
+ lines: list[str] = []
85
+ for i, p in enumerate(fn.params):
86
+ lines.append(f'local {p.name}="${i + 1}"')
87
+ if p.required and not p.default:
88
+ lines.append(f'if [ -z "${p.name}" ]; then')
89
+ lines.append(f' echo "{fn.key}: missing required argument: {p.name}" >&2')
90
+ lines.append(" return 1")
91
+ lines.append("fi")
92
+ return lines
93
+
94
+
95
+ def render_bashrc_function(fn: FunctionDef) -> str:
96
+ """Build the `name() { ... }` block registered into ~/.bashrc.
97
+
98
+ Blank lines are dropped from the body (blank-line-free is semantically identical
99
+ in bash) because `remove_bashrc_block` treats the first blank line after the
100
+ marker as the end of the block — a blank line inside the function would make
101
+ `disable` orphan everything after it, closing brace included.
102
+ """
103
+ body_lines = [f" {line}" for line in _bashrc_prelude(fn)]
104
+ body_lines += [f" {line}" for line in fn.script.rstrip("\n").splitlines() if line.strip()]
105
+ return f"{fn.key}() {{\n" + "\n".join(body_lines) + "\n}"
106
+
107
+
108
+ def enable_bashrc_function(fn: FunctionDef) -> bool:
109
+ return patch_bashrc(f"dev-setup-fn:{fn.key}", render_bashrc_function(fn))
110
+
111
+
112
+ def disable_bashrc_function(fn: FunctionDef) -> bool:
113
+ return remove_bashrc_block(f"dev-setup-fn:{fn.key}")
114
+
115
+
116
+ def run_script_function(
117
+ fn: FunctionDef,
118
+ args: tuple[str, ...],
119
+ *,
120
+ prompt: Callable[[FunctionParam], str] | None = None,
121
+ ) -> None:
122
+ """Execute a `script`-type function as a subprocess. Raises on nonzero exit."""
123
+ values = resolve_params(fn.params, args, prompt=prompt)
124
+ prelude = _positional_prelude(fn.params)
125
+ content = f"{prelude}\n{fn.script}" if prelude else fn.script
126
+
127
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
128
+ f.write(content)
129
+ tmp = f.name
130
+ try:
131
+ subprocess.run(["bash", tmp, *values], check=True)
132
+ finally:
133
+ os.unlink(tmp)
@@ -0,0 +1,106 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/thesawdawg/dev-setup-py/functions.schema.json",
4
+ "title": "devstuff functions catalog",
5
+ "description": "Schema for functions.yaml (bundled at src/dev_setup/functions.yaml, user overrides at ~/.config/dev-setup/functions.yaml). Defines reusable shell functions/scripts invoked via `devstuff run <key>` or, for bashrc-registered functions, called directly by name after `devstuff functions enable <key>`. Mirrors the validation in functions_catalog.py — keep both in sync.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version", "functions"],
9
+ "properties": {
10
+ "version": {
11
+ "const": 1,
12
+ "description": "Catalog schema version. Currently always 1."
13
+ },
14
+ "functions": {
15
+ "type": "object",
16
+ "description": "Map of function key -> function definition.",
17
+ "propertyNames": {
18
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
19
+ "description": "Lowercase letters, digits, hyphens, underscores; must start with a letter or digit."
20
+ },
21
+ "additionalProperties": { "$ref": "#/definitions/function" }
22
+ }
23
+ },
24
+ "definitions": {
25
+ "function": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "required": ["type", "script"],
29
+ "properties": {
30
+ "name": {
31
+ "type": "string",
32
+ "description": "Display name shown in `devstuff functions list`. Defaults to the catalog key if omitted."
33
+ },
34
+ "description": {
35
+ "type": "string",
36
+ "description": "Short description shown in `devstuff functions list`. Defaults to \"\"."
37
+ },
38
+ "category": {
39
+ "type": "string",
40
+ "description": "Group shown in `devstuff functions list` (functions are listed grouped by category, like tools). Freeform, not a fixed enum. Defaults to \"custom\"."
41
+ },
42
+ "type": {
43
+ "type": "string",
44
+ "enum": ["script", "shell-eval"],
45
+ "description": "Execution model. 'script': runs as a subprocess (like a tool's install_script) — for anything that just calls other binaries/apps and doesn't need to change your shell's state; params are resolved positionally and missing required ones are prompted for interactively. 'shell-eval': for things that must mutate the *calling* shell (env vars, cd, aliases, agents like ssh-agent) — a devstuff subprocess can't do this on its own, so see 'register' for how it actually takes effect."
46
+ },
47
+ "register": {
48
+ "type": "string",
49
+ "enum": ["bashrc", "eval"],
50
+ "description": "Only meaningful when type is 'shell-eval' — rejected for type 'script'. 'bashrc' (default): `devstuff functions enable <key>` patches a real shell function into ~/.bashrc (idempotent); afterward you call it directly by name in a new shell (`devstuff run` refuses to run these itself and points at `functions enable` instead). Required params without a default get a runtime guard baked into the generated function that fails loudly (prints to stderr, returns 1) if left blank. 'eval': `devstuff run <key> [args]` prints resolved shell code to stdout ONLY, for `eval \"$(devstuff run key args)\"` — no prompting, since anything else on stdout would corrupt the eval capture; a missing required param is reported on stderr and exits non-zero instead."
51
+ },
52
+ "params": {
53
+ "type": "array",
54
+ "description": "Named parameters, resolved positionally from CLI args in the order declared here. Each becomes a named shell variable in the script body (e.g. \"$key_path\", not positional \"$1\") via a runner-injected prelude. An explicitly empty value counts as missing for a required param, same as not passing it at all.",
55
+ "items": { "$ref": "#/definitions/param" }
56
+ },
57
+ "script": {
58
+ "type": "string",
59
+ "description": "Required. The bash script body, referencing params by name (e.g. \"$key_path\"). For bashrc-registered functions, keep this free of blank lines if you hand-edit — devstuff already strips them when rendering, since a blank line would make `functions disable` stop removing the block early and orphan the rest (closing brace included)."
60
+ },
61
+ "help_cmd": {
62
+ "type": "string",
63
+ "description": "Optional help command shown alongside the function in `devstuff functions list`."
64
+ },
65
+ "docs_url": {
66
+ "type": "string",
67
+ "format": "uri",
68
+ "description": "Optional documentation URL for this function."
69
+ }
70
+ },
71
+ "if": {
72
+ "properties": { "type": { "const": "shell-eval" } },
73
+ "required": ["type"]
74
+ },
75
+ "else": {
76
+ "properties": { "register": false }
77
+ }
78
+ },
79
+ "param": {
80
+ "type": "object",
81
+ "additionalProperties": false,
82
+ "required": ["name"],
83
+ "properties": {
84
+ "name": {
85
+ "type": "string",
86
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
87
+ "description": "Shell variable name this param is bound to in the script body. Must be a valid shell identifier; must be unique within the function."
88
+ },
89
+ "description": {
90
+ "type": "string",
91
+ "description": "Defaults to \"\". Shown as the prompt label when this param is missing and can be interactively prompted for (type: script only — shell-eval never prompts)."
92
+ },
93
+ "required": {
94
+ "type": "boolean",
95
+ "default": true,
96
+ "description": "Defaults to true. Whether this param must resolve to a non-empty value. For type: script / register: eval, an unresolved required param raises/exits before anything runs. For a bashrc-registered function, a required param with no default instead gets a runtime bash guard that fails at call time."
97
+ },
98
+ "default": {
99
+ "type": "string",
100
+ "default": "",
101
+ "description": "Defaults to \"\". Fallback value used when no value is supplied and (for type: script) no interactive prompt filled it in. A required param WITH a default never triggers the bashrc runtime guard or a resolution error, since the default always satisfies it."
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,111 @@
1
+ # yaml-language-server: $schema=./functions.schema.json
2
+ version: 1
3
+ functions:
4
+ ssh-agent-key:
5
+ name: SSH Agent + Add Key
6
+ description: Start ssh-agent in the current shell and add a key to it
7
+ category: auth
8
+ type: shell-eval
9
+ register: bashrc
10
+ params:
11
+ - name: key_path
12
+ description: Path to the SSH private key
13
+ required: true
14
+ script: |
15
+ eval "$(ssh-agent -s)"
16
+ ssh-add "$key_path"
17
+ docs_url: https://www.ssh.com/academy/ssh/agent
18
+
19
+ validate-docker-compose:
20
+ name: Validate Docker Compose
21
+ description: Validate a docker-compose.yml file in the current directory
22
+ category: validation
23
+ type: script
24
+ script: |
25
+ set -euo pipefail
26
+ if ! command -v docker >/dev/null 2>&1; then
27
+ echo "docker is required. Install it first: devstuff install docker" >&2
28
+ exit 1
29
+ fi
30
+ file=""
31
+ for candidate in docker-compose.yml docker-compose.yaml compose.yml compose.yaml; do
32
+ if [ -f "$candidate" ]; then
33
+ file="$candidate"
34
+ break
35
+ fi
36
+ done
37
+ if [ -z "$file" ]; then
38
+ echo "No docker-compose.yml/docker-compose.yaml/compose.yml/compose.yaml found in $(pwd)" >&2
39
+ exit 1
40
+ fi
41
+ echo "Validating $file..."
42
+ docker compose -f "$file" config --quiet
43
+ echo "$file is valid"
44
+ docs_url: https://docs.docker.com/reference/cli/docker/compose/config/
45
+
46
+ validate-yaml:
47
+ name: Validate YAML
48
+ description: Validate a YAML file's syntax using yq
49
+ category: validation
50
+ type: script
51
+ params:
52
+ - name: file
53
+ description: Path to the YAML file to validate
54
+ required: true
55
+ script: |
56
+ set -euo pipefail
57
+ if ! command -v yq >/dev/null 2>&1; then
58
+ echo "yq is required. Install it first: devstuff install yq" >&2
59
+ exit 1
60
+ fi
61
+ if [ ! -f "$file" ]; then
62
+ echo "File not found: $file" >&2
63
+ exit 1
64
+ fi
65
+ yq eval '.' "$file" > /dev/null
66
+ echo "$file is valid YAML"
67
+ docs_url: https://mikefarah.gitbook.io/yq
68
+
69
+ acc-check:
70
+ name: Accessibility/QA Check
71
+ description: Run the pi coding agent's /dogfood skill against a web URL
72
+ category: web-dev
73
+ type: script
74
+ params:
75
+ - name: url
76
+ description: URL to review
77
+ required: true
78
+ - name: instruction
79
+ description: Additional scope or instruction for the review (optional)
80
+ required: false
81
+ script: |
82
+ set -euo pipefail
83
+ . "$HOME/.nvm/nvm.sh" 2>/dev/null || true
84
+ if ! command -v pi >/dev/null 2>&1; then
85
+ echo "pi is required. Install it first: devstuff install pi" >&2
86
+ exit 1
87
+ fi
88
+ pi -p "/dogfood $url $instruction"
89
+ docs_url: https://pi.dev/docs/latest
90
+
91
+ aws-saml-reauth:
92
+ name: AWS SAML Reauth
93
+ description: Reauthorize the AWS CLI via saml2aws
94
+ category: web-dev
95
+ type: script
96
+ params:
97
+ - name: profile
98
+ description: AWS profile to reauthenticate (optional, uses saml2aws default if omitted)
99
+ required: false
100
+ script: |
101
+ set -euo pipefail
102
+ if ! command -v saml2aws >/dev/null 2>&1; then
103
+ echo "saml2aws is required. Install it first: devstuff install saml2aws" >&2
104
+ exit 1
105
+ fi
106
+ if [ -n "$profile" ]; then
107
+ saml2aws login --force --profile "$profile"
108
+ else
109
+ saml2aws login --force
110
+ fi
111
+ docs_url: https://github.com/Versent/saml2aws