devstuff 2.0.0__tar.gz → 2.1.0__tar.gz

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.
Files changed (161) hide show
  1. {devstuff-2.0.0 → devstuff-2.1.0}/CHANGELOG.md +6 -0
  2. {devstuff-2.0.0 → devstuff-2.1.0}/CLAUDE.md +8 -1
  3. {devstuff-2.0.0 → devstuff-2.1.0}/PKG-INFO +30 -1
  4. {devstuff-2.0.0 → devstuff-2.1.0}/README.md +29 -0
  5. {devstuff-2.0.0 → devstuff-2.1.0}/pyproject.toml +1 -1
  6. devstuff-2.1.0/src/dev_setup/functions.yaml +240 -0
  7. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_functions.py +125 -0
  8. {devstuff-2.0.0 → devstuff-2.1.0}/uv.lock +1 -1
  9. devstuff-2.0.0/src/dev_setup/functions.yaml +0 -111
  10. {devstuff-2.0.0 → devstuff-2.1.0}/.claude/skills/run-stuff/SKILL.md +0 -0
  11. {devstuff-2.0.0 → devstuff-2.1.0}/.claude/skills/run-stuff/smoke.sh +0 -0
  12. {devstuff-2.0.0 → devstuff-2.1.0}/.github/workflows/bump.yml +0 -0
  13. {devstuff-2.0.0 → devstuff-2.1.0}/.github/workflows/ci.yml +0 -0
  14. {devstuff-2.0.0 → devstuff-2.1.0}/.github/workflows/publish.yml +0 -0
  15. {devstuff-2.0.0 → devstuff-2.1.0}/.github/workflows/test-installs.yml +0 -0
  16. {devstuff-2.0.0 → devstuff-2.1.0}/.gitignore +0 -0
  17. {devstuff-2.0.0 → devstuff-2.1.0}/dev/Dockerfile +0 -0
  18. {devstuff-2.0.0 → devstuff-2.1.0}/dev/Dockerfile.ci +0 -0
  19. {devstuff-2.0.0 → devstuff-2.1.0}/dev/Makefile +0 -0
  20. {devstuff-2.0.0 → devstuff-2.1.0}/dev/docker-compose.yml +0 -0
  21. {devstuff-2.0.0 → devstuff-2.1.0}/devstuff +0 -0
  22. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/README.md +0 -0
  23. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/agent/development-plan.md +0 -0
  24. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/agent/specifications.md +0 -0
  25. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/agent/stack-decisions.md +0 -0
  26. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/ansible-config/development-plan.md +0 -0
  27. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/ansible-config/specifications.md +0 -0
  28. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/ansible-config/stack-decisions.md +0 -0
  29. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/bat-config/development-plan.md +0 -0
  30. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/bat-config/specifications.md +0 -0
  31. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/bat-config/stack-decisions.md +0 -0
  32. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/commitizen-config/development-plan.md +0 -0
  33. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/commitizen-config/specifications.md +0 -0
  34. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/commitizen-config/stack-decisions.md +0 -0
  35. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/docker-config/development-plan.md +0 -0
  36. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/docker-config/specifications.md +0 -0
  37. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/docker-config/stack-decisions.md +0 -0
  38. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/lazygit-config/development-plan.md +0 -0
  39. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/lazygit-config/specifications.md +0 -0
  40. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/lazygit-config/stack-decisions.md +0 -0
  41. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/precommit-config/development-plan.md +0 -0
  42. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/precommit-config/specifications.md +0 -0
  43. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/precommit-config/stack-decisions.md +0 -0
  44. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/starship-config/development-plan.md +0 -0
  45. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/starship-config/specifications.md +0 -0
  46. {devstuff-2.0.0 → devstuff-2.1.0}/docs/specs/starship-config/stack-decisions.md +0 -0
  47. {devstuff-2.0.0 → devstuff-2.1.0}/install.sh +0 -0
  48. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/__init__.py +0 -0
  49. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/__main__.py +0 -0
  50. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/__init__.py +0 -0
  51. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/approval.py +0 -0
  52. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/bridges.py +0 -0
  53. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/catalog.py +0 -0
  54. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/completion.py +0 -0
  55. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/config.py +0 -0
  56. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/loop.py +0 -0
  57. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/ollama.py +0 -0
  58. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/preflight.py +0 -0
  59. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/primitives.py +0 -0
  60. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/registry.py +0 -0
  61. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/sandbox.py +0 -0
  62. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/session.py +0 -0
  63. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/transcript.py +0 -0
  64. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent/wizard.py +0 -0
  65. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent_tools.schema.json +0 -0
  66. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/agent_tools.yaml +0 -0
  67. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/base.py +0 -0
  68. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/catalog.py +0 -0
  69. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/cli.py +0 -0
  70. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/__init__.py +0 -0
  71. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/add_cmd.py +0 -0
  72. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/agent_cmd.py +0 -0
  73. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/catalog_cmd.py +0 -0
  74. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/configure_cmd.py +0 -0
  75. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/delete_cmd.py +0 -0
  76. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/docs_cmd.py +0 -0
  77. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/doctor_cmd.py +0 -0
  78. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/functions_cmd.py +0 -0
  79. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/help_cmd.py +0 -0
  80. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/install_cmd.py +0 -0
  81. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/list_cmd.py +0 -0
  82. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/remove_cmd.py +0 -0
  83. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/run_cmd.py +0 -0
  84. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/skills_cmd.py +0 -0
  85. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/commands/update_cmd.py +0 -0
  86. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/__init__.py +0 -0
  87. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/ansible/__init__.py +0 -0
  88. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/ansible/detect.py +0 -0
  89. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/ansible/model.py +0 -0
  90. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/ansible/render.py +0 -0
  91. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/ansible/validate.py +0 -0
  92. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/ansible/wizard.py +0 -0
  93. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/bat/__init__.py +0 -0
  94. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/bat/detect.py +0 -0
  95. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/bat/model.py +0 -0
  96. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/bat/preview.py +0 -0
  97. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/bat/render.py +0 -0
  98. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/bat/wizard.py +0 -0
  99. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/commitizen/__init__.py +0 -0
  100. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/commitizen/detect.py +0 -0
  101. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/commitizen/model.py +0 -0
  102. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/commitizen/render.py +0 -0
  103. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/commitizen/validate.py +0 -0
  104. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/commitizen/wizard.py +0 -0
  105. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/docker/__init__.py +0 -0
  106. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/docker/detect.py +0 -0
  107. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/docker/model.py +0 -0
  108. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/docker/render.py +0 -0
  109. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/docker/validate.py +0 -0
  110. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/docker/wizard.py +0 -0
  111. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/lazygit/__init__.py +0 -0
  112. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/lazygit/detect.py +0 -0
  113. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/lazygit/model.py +0 -0
  114. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/lazygit/render.py +0 -0
  115. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/lazygit/validate.py +0 -0
  116. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/lazygit/wizard.py +0 -0
  117. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/precommit/__init__.py +0 -0
  118. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/precommit/detect.py +0 -0
  119. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/precommit/model.py +0 -0
  120. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/precommit/render.py +0 -0
  121. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/precommit/validate.py +0 -0
  122. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/precommit/wizard.py +0 -0
  123. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/starship/__init__.py +0 -0
  124. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/starship/fonts.py +0 -0
  125. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/starship/model.py +0 -0
  126. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/starship/preview.py +0 -0
  127. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/starship/render.py +0 -0
  128. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/configure/starship/wizard.py +0 -0
  129. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/doctor.py +0 -0
  130. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/function_runner.py +0 -0
  131. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/functions.schema.json +0 -0
  132. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/functions_catalog.py +0 -0
  133. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/functions_registry.py +0 -0
  134. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/generic.py +0 -0
  135. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/registry.py +0 -0
  136. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/tools.yaml +0 -0
  137. {devstuff-2.0.0 → devstuff-2.1.0}/src/dev_setup/ui.py +0 -0
  138. {devstuff-2.0.0 → devstuff-2.1.0}/tests/__init__.py +0 -0
  139. {devstuff-2.0.0 → devstuff-2.1.0}/tests/integration/__init__.py +0 -0
  140. {devstuff-2.0.0 → devstuff-2.1.0}/tests/integration/conftest.py +0 -0
  141. {devstuff-2.0.0 → devstuff-2.1.0}/tests/integration/test_agent_smoke.py +0 -0
  142. {devstuff-2.0.0 → devstuff-2.1.0}/tests/integration/test_agent_tui.py +0 -0
  143. {devstuff-2.0.0 → devstuff-2.1.0}/tests/integration/test_commitizen_config.py +0 -0
  144. {devstuff-2.0.0 → devstuff-2.1.0}/tests/integration/test_tools.py +0 -0
  145. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_agent.py +0 -0
  146. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_agent_completion.py +0 -0
  147. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_agent_loop.py +0 -0
  148. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_agent_sandbox.py +0 -0
  149. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_agent_tools.py +0 -0
  150. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_agent_wizard.py +0 -0
  151. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_catalog.py +0 -0
  152. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_configure_ansible.py +0 -0
  153. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_configure_bat.py +0 -0
  154. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_configure_commitizen.py +0 -0
  155. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_configure_docker.py +0 -0
  156. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_configure_lazygit.py +0 -0
  157. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_configure_precommit.py +0 -0
  158. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_configure_starship.py +0 -0
  159. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_doctor.py +0 -0
  160. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_generic.py +0 -0
  161. {devstuff-2.0.0 → devstuff-2.1.0}/tests/test_registry.py +0 -0
@@ -1,3 +1,9 @@
1
+ ## v2.1.0 (2026-08-04)
2
+
3
+ ### Features
4
+
5
+ - **functions**: add whats-on-port to find what is holding a port
6
+
1
7
  ## v2.0.0 (2026-08-04)
2
8
 
3
9
  ### BREAKING CHANGE
@@ -165,7 +165,14 @@ another CLI should guard on `command -v <tool>` and point at `devstuff install <
165
165
  error rather than let a raw "command not found" surface — see `validate-yaml`/`aws-saml-reauth`
166
166
  in `functions.yaml`. If that CLI is only reachable via nvm (like `pi`), source
167
167
  `"$HOME/.nvm/nvm.sh"` first (see `acc-check`) — `script`-type functions run via a non-login,
168
- non-interactive `bash <tmpfile>`, so `~/.bashrc`/nvm's shell init never runs on their own.
168
+ non-interactive `bash <tmpfile>`, so `~/.bashrc`/nvm's shell init never runs on their own. When
169
+ the CLI isn't a catalog tool at all, name the distro package instead (`whats-on-port` points at
170
+ `apt-get install iproute2`) — pointing at `devstuff install ss` would be a lie.
171
+
172
+ **`run_cmd` flattens every non-zero exit to 1**, so a `script` function cannot signal a *result*
173
+ through its exit code — only whether it ran. A "found nothing" answer should therefore exit 0 and
174
+ say so, or the user gets a red "command failed" banner under a correct result; keep non-zero for
175
+ "could not perform the lookup" (see `whats-on-port`'s comment on this).
169
176
 
170
177
  Not yet built: an `add` wizard and `catalog import`/`export` for functions, analogous to the
171
178
  ones tools already have.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devstuff
3
- Version: 2.0.0
3
+ Version: 2.1.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>
@@ -1105,9 +1105,38 @@ ones tools already have — for now, custom functions are hand-edited YAML at
1105
1105
  | `ssh-agent-key` | auth | shell-eval (bashrc) | Start ssh-agent in the current shell and add a key to it | `key_path` |
1106
1106
  | `validate-docker-compose` | validation | script | Validate a docker-compose.yml file in the current directory | — |
1107
1107
  | `validate-yaml` | validation | script | Validate a YAML file's syntax using `yq` | `file` |
1108
+ | `whats-on-port` | network | script | Find which process is listening on a port | `port`, `protocol` (optional: `tcp`/`udp`/`all`) |
1108
1109
  | `acc-check` | web-dev | script | Run the pi coding agent's `/dogfood` skill against a web URL | `url`, `instruction` (optional) |
1109
1110
  | `aws-saml-reauth` | web-dev | script | Reauthorize the AWS CLI via `saml2aws login --force` | `profile` (optional) |
1110
1111
 
1112
+ #### `whats-on-port`
1113
+
1114
+ ```bash
1115
+ devstuff run whats-on-port 8080 # tcp and udp
1116
+ devstuff run whats-on-port 5432 tcp # one protocol
1117
+ ```
1118
+
1119
+ Prints the matching sockets, then the PID, user, elapsed time and **untruncated** command
1120
+ line of every process holding them — plural because `SO_REUSEPORT` lets several processes
1121
+ share one port, and "which of these five node processes" is usually the actual question.
1122
+
1123
+ It uses `ss` rather than `lsof` or `fuser` for one measured reason: **run unprivileged
1124
+ against another user's socket, `lsof` and `fuser` print nothing and exit as though the port
1125
+ were free.** `ss` still lists the socket, just without the `users:((...))` field — so the
1126
+ function can tell "nothing there" apart from "something there I'm not allowed to name", and
1127
+ says which. When the process is hidden it retries under `sudo -n`, and only if that needs no
1128
+ password; a diagnostic shouldn't stop to prompt for one. If sudo isn't available it says
1129
+ what to re-run.
1130
+
1131
+ "Nothing is listening" is reported as a success, not a failure, along with the two reasons a
1132
+ bind can still fail afterwards: a container port published with `userland-proxy: false` is
1133
+ NAT-forwarded with no host socket to find at all, and sockets in another network namespace
1134
+ are invisible. If Docker is running, published container ports are matched and named too —
1135
+ `docker-proxy`'s own command line doesn't say which container it belongs to.
1136
+
1137
+ `ss` comes from `iproute2`, which isn't a devstuff package and isn't on every minimal image,
1138
+ so a missing `ss` names the apt package instead of surfacing "command not found".
1139
+
1111
1140
  ---
1112
1141
 
1113
1142
  ## Built-in packages
@@ -1078,9 +1078,38 @@ ones tools already have — for now, custom functions are hand-edited YAML at
1078
1078
  | `ssh-agent-key` | auth | shell-eval (bashrc) | Start ssh-agent in the current shell and add a key to it | `key_path` |
1079
1079
  | `validate-docker-compose` | validation | script | Validate a docker-compose.yml file in the current directory | — |
1080
1080
  | `validate-yaml` | validation | script | Validate a YAML file's syntax using `yq` | `file` |
1081
+ | `whats-on-port` | network | script | Find which process is listening on a port | `port`, `protocol` (optional: `tcp`/`udp`/`all`) |
1081
1082
  | `acc-check` | web-dev | script | Run the pi coding agent's `/dogfood` skill against a web URL | `url`, `instruction` (optional) |
1082
1083
  | `aws-saml-reauth` | web-dev | script | Reauthorize the AWS CLI via `saml2aws login --force` | `profile` (optional) |
1083
1084
 
1085
+ #### `whats-on-port`
1086
+
1087
+ ```bash
1088
+ devstuff run whats-on-port 8080 # tcp and udp
1089
+ devstuff run whats-on-port 5432 tcp # one protocol
1090
+ ```
1091
+
1092
+ Prints the matching sockets, then the PID, user, elapsed time and **untruncated** command
1093
+ line of every process holding them — plural because `SO_REUSEPORT` lets several processes
1094
+ share one port, and "which of these five node processes" is usually the actual question.
1095
+
1096
+ It uses `ss` rather than `lsof` or `fuser` for one measured reason: **run unprivileged
1097
+ against another user's socket, `lsof` and `fuser` print nothing and exit as though the port
1098
+ were free.** `ss` still lists the socket, just without the `users:((...))` field — so the
1099
+ function can tell "nothing there" apart from "something there I'm not allowed to name", and
1100
+ says which. When the process is hidden it retries under `sudo -n`, and only if that needs no
1101
+ password; a diagnostic shouldn't stop to prompt for one. If sudo isn't available it says
1102
+ what to re-run.
1103
+
1104
+ "Nothing is listening" is reported as a success, not a failure, along with the two reasons a
1105
+ bind can still fail afterwards: a container port published with `userland-proxy: false` is
1106
+ NAT-forwarded with no host socket to find at all, and sockets in another network namespace
1107
+ are invisible. If Docker is running, published container ports are matched and named too —
1108
+ `docker-proxy`'s own command line doesn't say which container it belongs to.
1109
+
1110
+ `ss` comes from `iproute2`, which isn't a devstuff package and isn't on every minimal image,
1111
+ so a missing `ss` names the apt package instead of surfacing "command not found".
1112
+
1084
1113
  ---
1085
1114
 
1086
1115
  ## Built-in packages
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devstuff"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "Development environment setup CLI for Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,240 @@
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
+ 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
+
220
+ aws-saml-reauth:
221
+ name: AWS SAML Reauth
222
+ description: Reauthorize the AWS CLI via saml2aws
223
+ category: web-dev
224
+ type: script
225
+ params:
226
+ - name: profile
227
+ description: AWS profile to reauthenticate (optional, uses saml2aws default if omitted)
228
+ required: false
229
+ script: |
230
+ set -euo pipefail
231
+ if ! command -v saml2aws >/dev/null 2>&1; then
232
+ echo "saml2aws is required. Install it first: devstuff install saml2aws" >&2
233
+ exit 1
234
+ fi
235
+ if [ -n "$profile" ]; then
236
+ saml2aws login --force --profile "$profile"
237
+ else
238
+ saml2aws login --force
239
+ fi
240
+ docs_url: https://github.com/Versent/saml2aws
@@ -381,3 +381,128 @@ def test_captured_failure_carries_output_on_the_exception():
381
381
  with pytest.raises(subprocess.CalledProcessError) as exc:
382
382
  runner.run_script_function(fn, (), capture=True)
383
383
  assert "why it failed" in exc.value.stdout
384
+
385
+
386
+ # -- whats-on-port ------------------------------------------------------------------
387
+
388
+
389
+ def _bundled(key: str) -> FunctionDef:
390
+ return FunctionDef.from_dict(catalog.load_bundled_catalog()[key], key=key)
391
+
392
+
393
+ def test_whats_on_port_is_declared_the_way_it_is_documented(isolated_catalog):
394
+ fns = catalog.load_bundled_catalog()
395
+ fn = fns["whats-on-port"]
396
+ assert fn["type"] == "script"
397
+ assert fn["category"] == "network"
398
+ port, protocol = fn["params"]
399
+ assert (port["name"], port["required"]) == ("port", True)
400
+ # Optional with a real default, so `devstuff run whats-on-port 8080` never prompts
401
+ # for a second argument.
402
+ assert (protocol["name"], protocol["required"], protocol["default"]) == (
403
+ "protocol", False, "all",
404
+ )
405
+
406
+
407
+ @pytest.mark.parametrize("key", sorted(catalog.load_bundled_catalog()))
408
+ def test_every_bundled_script_is_valid_bash(key):
409
+ """`bash -n` over the script exactly as the runner assembles it — prelude included,
410
+ since that is what defines the param variables the body reads under `set -u`."""
411
+ import subprocess
412
+
413
+ fn = _bundled(key)
414
+ prelude = runner._positional_prelude(fn.params)
415
+ content = f"{prelude}\n{fn.script}" if prelude else fn.script
416
+ result = subprocess.run(
417
+ ["bash", "-n"], input=content, capture_output=True, text=True, check=False
418
+ )
419
+ assert result.returncode == 0, f"{key}: {result.stderr}"
420
+
421
+
422
+ # The behavioural tests below bind a real socket and shell out to `ss`. No sudo and no
423
+ # network — but `ss` is not on every image (this repo's own CI container has lsof and
424
+ # no ss), which is the same gap the function itself guards against.
425
+ needs_ss = pytest.mark.skipif(
426
+ __import__("shutil").which("ss") is None, reason="ss (iproute2) is not installed"
427
+ )
428
+
429
+
430
+ @pytest.fixture()
431
+ def listening_port():
432
+ """A real TCP listener owned by this process, and its port."""
433
+ import socket
434
+
435
+ sock = socket.socket()
436
+ sock.bind(("127.0.0.1", 0))
437
+ sock.listen(1)
438
+ try:
439
+ yield sock.getsockname()[1]
440
+ finally:
441
+ sock.close()
442
+
443
+
444
+ @pytest.fixture()
445
+ def free_port():
446
+ """A port nothing holds: bound to claim it from the ephemeral range, then released.
447
+
448
+ `listening_port + 1` would do almost always, which is exactly the kind of almost
449
+ that fails in CI once a month.
450
+ """
451
+ import socket
452
+
453
+ sock = socket.socket()
454
+ sock.bind(("127.0.0.1", 0))
455
+ port = sock.getsockname()[1]
456
+ sock.close()
457
+ return port
458
+
459
+
460
+ def _run(port, protocol="") -> str:
461
+ args = (str(port), protocol) if protocol else (str(port),)
462
+ return runner.run_script_function(_bundled("whats-on-port"), args, capture=True)
463
+
464
+
465
+ @needs_ss
466
+ def test_it_names_the_process_holding_the_port(listening_port):
467
+ import os
468
+
469
+ output = _run(listening_port)
470
+ assert f"Port {listening_port} (all) is in use" in output
471
+ # The point of the whole function: the PID, not just "something is there".
472
+ assert str(os.getpid()) in output
473
+ assert "Holding process(es):" in output
474
+
475
+
476
+ @needs_ss
477
+ def test_a_free_port_is_a_successful_answer_not_a_failure(free_port):
478
+ """`run_cmd` flattens every non-zero exit to 1, so exiting non-zero for "found
479
+ nothing" would only paint a red banner under a correct result."""
480
+ output = _run(free_port) # capture=True raises CalledProcessError on non-zero
481
+ assert f"Nothing is listening on port {free_port}" in output
482
+ # And it does not leave the user thinking a bind will now succeed.
483
+ assert "NAT" in output
484
+
485
+
486
+ @needs_ss
487
+ def test_the_protocol_filter_excludes_the_other_protocol(listening_port):
488
+ assert "is in use" in _run(listening_port, "tcp")
489
+ assert "Nothing is listening" in _run(listening_port, "udp")
490
+
491
+
492
+ @pytest.mark.parametrize("port", ["abc", "0", "70000", "-1", "80.5"])
493
+ def test_an_impossible_port_is_rejected_before_anything_runs(port):
494
+ import subprocess
495
+
496
+ with pytest.raises(subprocess.CalledProcessError) as exc:
497
+ _run(port)
498
+ assert exc.value.returncode == 2
499
+ assert "between 1 and 65535" in exc.value.stdout + exc.value.stderr
500
+
501
+
502
+ def test_an_unknown_protocol_is_rejected():
503
+ import subprocess
504
+
505
+ with pytest.raises(subprocess.CalledProcessError) as exc:
506
+ _run(8080, "sctp")
507
+ assert exc.value.returncode == 2
508
+ assert "tcp, udp or all" in exc.value.stdout + exc.value.stderr
@@ -167,7 +167,7 @@ wheels = [
167
167
 
168
168
  [[package]]
169
169
  name = "devstuff"
170
- version = "2.0.0"
170
+ version = "2.1.0"
171
171
  source = { editable = "." }
172
172
  dependencies = [
173
173
  { name = "click" },
@@ -1,111 +0,0 @@
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes