@codebend3r/che 1.0.0 → 1.2.0

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 (53) hide show
  1. package/README.md +65 -25
  2. package/bin/art.py +123 -0
  3. package/bin/che.py +209 -568
  4. package/bin/commands.py +220 -231
  5. package/bin/doctor.py +220 -0
  6. package/bin/drives/eject-all-drives.py +21 -38
  7. package/bin/drives/mount-all-drives.py +18 -62
  8. package/bin/drives/ping-nas.py +77 -105
  9. package/bin/files/compress-folders.py +7 -11
  10. package/bin/files/delete-by-ext.py +10 -14
  11. package/bin/files/delete-empty-folders.py +33 -37
  12. package/bin/files/delete-smb-files.py +12 -21
  13. package/bin/files/files-under-size.py +5 -11
  14. package/bin/files/find-largest-files.py +21 -25
  15. package/bin/files/list-permission.py +40 -0
  16. package/bin/files/make-alpha-dir.py +2 -0
  17. package/bin/git/all-actions.py +350 -253
  18. package/bin/git/checkout-my-branches.py +42 -63
  19. package/bin/git/clean-stale-branches.py +23 -48
  20. package/bin/git/prune-worktrees.py +12 -51
  21. package/bin/git/sync-all-branches.py +54 -283
  22. package/bin/git/update-local-branches.py +67 -73
  23. package/bin/gitutils.py +263 -0
  24. package/bin/install.py +393 -1188
  25. package/bin/machine.py +345 -0
  26. package/bin/media.py +127 -0
  27. package/bin/menu.py +437 -0
  28. package/bin/nas.py +102 -0
  29. package/bin/rcfile.py +380 -0
  30. package/bin/shellgen.py +508 -428
  31. package/bin/system/btop-launch.py +17 -13
  32. package/bin/system/update-brew.py +57 -68
  33. package/bin/tui.py +156 -153
  34. package/bin/utils.py +165 -237
  35. package/bin/video/delete-duplicate-videos.py +64 -62
  36. package/bin/video/detect-green-magenta-videos.py +105 -95
  37. package/bin/video/find-movie-by-year.py +3 -0
  38. package/bin/video/find-video-mkv-issues.py +48 -65
  39. package/bin/video/fix-codecs.py +25 -30
  40. package/bin/video/largest-tv-shows.py +60 -57
  41. package/bin/video/remove-metadata.py +3 -6
  42. package/bin/video/rename-video-file.py +74 -84
  43. package/bin/video/scan-videos-audio-language.py +16 -33
  44. package/bin/video/show-codecs.py +20 -24
  45. package/bin/video/validate-video-files.py +5 -8
  46. package/bin/video/video-list.py +17 -20
  47. package/package.json +4 -10
  48. package/shell/che.bash +33 -33
  49. package/shell/che.fish +7 -7
  50. package/shell/che.zsh +33 -33
  51. package/shell/completions/_che +19 -19
  52. package/shell/completions/che.bash +15 -15
  53. package/shell/completions/che.fish +34 -50
package/README.md CHANGED
@@ -5,14 +5,39 @@
5
5
  **A collection of helper scripts shared amongst computers, called by name from
6
6
  your shell, and browsable through one menu.**
7
7
 
8
- [![CI](https://github.com/codebend3r/che/actions/workflows/ci.yml/badge.svg)](https://github.com/codebend3r/che/actions/workflows/ci.yml)
8
+ [![CI](https://github.com/codebend3r/che/actions/workflows/sanity-check.yml/badge.svg)](https://github.com/codebend3r/che/actions/workflows/sanity-check.yml)
9
9
  [![npm](https://img.shields.io/npm/v/%40codebend3r%2Fche)](https://www.npmjs.com/package/@codebend3r/che)
10
10
  ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
11
11
  ![Dependencies: stdlib only](https://img.shields.io/badge/dependencies-stdlib%20only-brightgreen)
12
12
  ![Shells: zsh, bash, fish](https://img.shields.io/badge/shells-zsh%20%7C%20bash%20%7C%20fish-8A2BE2)
13
13
 
14
- <img src="menu.png" alt="The che menu: commands grouped by category on the left, details for the selected command on the right" width="850">
14
+ </div>
15
+
16
+ <!-- che:banner -->
17
+ ```text
18
+ **#*-
19
+ #*-. .::---:::.
20
+ = .:+#%@@@@@@@@@%#*+-.
21
+ .-*%@@@@@@@@@*==----+*-
22
+ .=-====*%@@@@+:..:===--+:
23
+ . .-:. .=%@%+=-+:...=+==
24
+ .=:-.:=:.+#***+***+++**+. ██████╗██╗ ██╗███████╗
25
+ -.:++**+-#@%##%%####%##*- ██╔════╝██║ ██║██╔════╝
26
+ .=+**#%#-:-+=--+%%@@####+. ██║ ███████║█████╗
27
+ .+*#%@@*...=+::+##%%#***-. ██║ ██╔══██║██╔══╝
28
+ -*##%@*=:-#*=-:--==##*+:. ╚██████╗██║ ██║███████╗
29
+ .=+#+-::--::-:.:.: :++=:. ╚═════╝╚═╝ ╚═╝╚══════╝
30
+ :++...::::---==++:---...
31
+ :..=**+++-=*#%@%=:::..
32
+ .:-*%@@@@@@@%#*. :::
33
+ : . . -+**#*=-.
34
+ ++-. .::
35
+ +*+=: .-===.
36
+ ```
37
+ <!-- /che:banner -->
15
38
 
39
+ <div align="center">
40
+ <img src="menu.png" alt="The che menu: commands grouped by category on the left, details for the selected command on the right" width="850">
16
41
  </div>
17
42
 
18
43
  The scripts are Python, standard library only. The names you type
@@ -54,8 +79,8 @@ git clone https://github.com/codebend3r/che.git ~/Developer/git/che
54
79
  ~/Developer/git/che/install.sh
55
80
  ```
56
81
 
57
- Or in one line, which clones for you (into `~/Developer/git/shell-functions`,
58
- or wherever `CHE_HOME` points):
82
+ Or in one line, which clones for you (into `~/Developer/git/che`, or wherever
83
+ `CHE_HOME` points):
59
84
 
60
85
  ```sh
61
86
  curl -fsSL https://raw.githubusercontent.com/codebend3r/che/main/install.sh | bash
@@ -78,7 +103,9 @@ which opens a first-run wizard: it lists the shells it found and the rc files
78
103
  it would touch, asks whether to add a `che` on `PATH` and whether to keep the
79
104
  completion chimes, offers to remove hand-written wrappers left over from before
80
105
  the installer existed, and reports any missing external tool (offering to
81
- `brew install` them when Homebrew is present).
106
+ `brew install` them when Homebrew is present). A question already answered on
107
+ the command line (`--no-sounds`, `--no-path-shim`, `--replace-legacy`,
108
+ `--install-deps`) is not asked again.
82
109
 
83
110
  <div align="center">
84
111
  <img src="first-run.png" alt="The first run screen: che is not installed in this shell yet, offering to install, open the menu, or quit" width="700">
@@ -93,7 +120,7 @@ Skip the wizard:
93
120
  ./install.sh --yes --shells=zsh,fish # or the ones you name
94
121
  ./install.sh --dry-run # report what would change, write nothing
95
122
  ./install.sh --yes --replace-legacy # also remove pre-che hand-written wrappers
96
- ./install.sh --print # print the rc block, install nothing
123
+ ./install.sh --print # print the exact rc block, install nothing
97
124
  ```
98
125
 
99
126
  Every rc file is copied to `~/.config/che/backups/` before it is edited.
@@ -114,9 +141,9 @@ dotfiles:
114
141
  ```zsh
115
142
  # >>> che shell functions >>>
116
143
  # Managed by `che install` (che 1.0.0). Edits here are overwritten.
117
- export CHE_HOME=/Users/you/Developer/git/shell-functions
144
+ export CHE_HOME=/Users/you/Developer/git/che
118
145
  export CHE_PYTHON=/opt/homebrew/bin/python3
119
- [ -r /Users/you/Developer/git/shell-functions/shell/che.zsh ] && . /Users/you/Developer/git/shell-functions/shell/che.zsh
146
+ [ -r /Users/you/Developer/git/che/shell/che.zsh ] && . /Users/you/Developer/git/che/shell/che.zsh
120
147
  case ":$PATH:" in *":/Users/you/.local/bin:"*) ;; *) PATH="/Users/you/.local/bin:$PATH" ;; esac
121
148
  # <<< che shell functions <<<
122
149
  ```
@@ -139,7 +166,7 @@ python3 exists. `brew install python@3.13` if none does.
139
166
 
140
167
  ```sh
141
168
  che uninstall # remove the block, the shim and the completions
142
- che uninstall --purge # also delete ~/.config/che
169
+ che uninstall --purge # also delete ~/.config/che and the menu's input history
143
170
  ```
144
171
 
145
172
  The repo itself is left alone; delete the directory to finish.
@@ -189,13 +216,13 @@ have used, and `che help <command>` prints that script's own `--help`.
189
216
  | Command | Does | Preview |
190
217
  | --- | --- | --- |
191
218
  | **Git** | *Branch hygiene, worktrees and GitHub Actions* | |
192
- | `all-actions` | GitHub Actions status for every open PR you authored | |
219
+ | `all-actions` | GitHub Actions status for every open PR and every default branch | |
193
220
  | `all-actions-watch` | Same as all-actions, refreshed on an interval | |
194
221
  | `checkout-my-branches` | Check out recent remote branches you authored that aren't local yet | |
195
222
  | `clean-stale-branches` | Delete local branches whose upstream is gone | `clean-stale-branches-dr` |
196
223
  | `prune-worktrees` | Remove every linked worktree, then prune stale admin records | `prune-worktrees-dr` |
197
- | `sync-all-branches` | Push every worktree, collapse the pushed ones, then tidy branches | `sync-all-branches-dr` |
198
- | `update-from-origin` | Same as sync-all-branches but never pushes: fetch, collapse, rebase | |
224
+ | `sync-all-branches` | Delete merged branches, rebase the rest, check out new ones; branches only | `sync-all-branches-dr` |
225
+ | `update-from-origin` | Alias of sync-all-branches | `--dry-run` |
199
226
  | `update-local-branches` | Rebase every local branch that has an upstream onto origin | `--dry-run` |
200
227
  | **Video** | *Codecs, renaming, dedupe and metadata* | |
201
228
  | `show-codecs` | Report media files outside the Direct Play codec/container set | |
@@ -290,9 +317,17 @@ where you copy one machine's rc file to another.
290
317
 
291
318
  ```
292
319
  bin/
293
- che.py the dispatcher and the interactive menu
320
+ art.py the wordmark and the face beside it, for every screen
321
+ che.py the dispatcher, and the loop that runs what the menu picks
294
322
  commands.py the command manifest: the source of truth for everything
295
- install.py installer, first-run wizard, doctor, self-update
323
+ doctor.py `che doctor`: every check on the install
324
+ gitutils.py shared git helpers: worktrees, main branch, hook skipping
325
+ install.py installer, first-run wizard, self-update, uninstall
326
+ machine.py shells, rc file paths, python, config: facts about the machine
327
+ media.py shared ffprobe helpers for the video scripts
328
+ menu.py the menu's list, keys and drawing
329
+ nas.py the NAS drive table and SMB mounting, for the drive scripts
330
+ rcfile.py edits to rc files: the che block and old hand-written wrappers
296
331
  shellgen.py renders the wrappers, completions and README tables
297
332
  tui.py terminal primitives: raw input, frame buffer, prompts
298
333
  utils.py shared library: logging, arg parsing, subprocess, files
@@ -325,22 +360,27 @@ Every script follows the same shape, described in full at the top of
325
360
  point, so pointing a delete tool at `~/media -> /Volumes/...` used to be a
326
361
  silent no-op. Pass the real path to act on it.
327
362
 
328
- ## Syncing branches and worktrees
363
+ ## Syncing branches
329
364
 
330
365
  ```sh
331
- sync-all-branches # push every worktree, collapse the pushed ones, then sync
366
+ sync-all-branches # delete merged branches, rebase the rest, check out new ones
332
367
  sync-all-branches-dr # preview only
333
- update-from-origin # same, but --no-push: only collapses already-pushed worktrees
368
+ update-from-origin # alias of sync-all-branches
334
369
  ```
335
370
 
336
- A worktree is a temporary workspace. Once its commits are on origin the
337
- directory holds nothing the remote doesn't, so `sync-all-branches` pushes it,
338
- removes the directory, and leaves the branch behind as an ordinary local
339
- branch. That also clears the `already used by worktree` pin, which is what
340
- otherwise makes `main` un-checkoutable and stale branches un-deletable.
371
+ Only branches change. The sync deletes local branches whose upstream is gone,
372
+ `pull --rebase`s every branch with an upstream, and creates local tracking
373
+ branches for the remote branches you authored that aren't local yet, without
374
+ switching to them. It ends with the main clone on `main`.
341
375
 
342
- A worktree is only collapsed when it is **clean** *and* **fully pushed**.
343
- Anything else is kept and reported with the reason.
376
+ It **never pushes**, **never touches a worktree** and **never runs the repo's
377
+ git hooks**. A branch checked out in a linked worktree is rebased in place
378
+ there when that worktree is clean; a stale one is reported and kept, since git
379
+ won't delete a branch a worktree has checked out. The branch scripts
380
+ (`clean-stale-branches`, `update-local-branches`, `checkout-my-branches`,
381
+ `prune-worktrees`, `sync-all-branches`) point `core.hooksPath` at `/dev/null`
382
+ for every git they run, so a lefthook or husky pre-commit, pre-push or
383
+ post-checkout never fires on a checkout or a rebase.
344
384
 
345
385
  ## External tools
346
386
 
@@ -382,7 +422,7 @@ This repo uses [Bun](https://bun.sh) as its task runner and
382
422
  ```sh
383
423
  bun run test # pytest
384
424
  bun run generate # rewrite shell/ and this README's tables from the manifest
385
- bun run ci # ruff + syntax + exec bits + shebangs + generated files + pytest
425
+ bun run ci # ruff + exec bits + shebangs + pytest (stale generated files fail it)
386
426
  ```
387
427
 
388
428
  Neither `pytest` nor `ruff` needs to be installed globally: the scripts shell
package/bin/art.py ADDED
@@ -0,0 +1,123 @@
1
+ """The che wordmark and the face it shares a name with, as text.
2
+
3
+ ``LOGO`` is the block-letter wordmark that ``che install``, ``che doctor`` and
4
+ the first-run screen print. ``FACE`` is Che Guevara, rendered from a photograph
5
+ to 32 columns by 18 rows so the two fit side by side in an 80-column terminal.
6
+ Dense characters are the bright parts of the photo, which reads correctly on a
7
+ dark background.
8
+
9
+ ``banner()`` lays the face beside whatever a screen wants next to it, normally
10
+ the wordmark with a subtitle underneath, centred vertically on the face. Lines
11
+ may carry colour escapes; widths are measured with ``tui.display_width`` so the
12
+ escapes do not push the right-hand column about.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+
19
+ import tui
20
+
21
+ LOGO = r"""
22
+ ██████╗██╗ ██╗███████╗
23
+ ██╔════╝██║ ██║██╔════╝
24
+ ██║ ███████║█████╗
25
+ ██║ ██╔══██║██╔══╝
26
+ ╚██████╗██║ ██║███████╗
27
+ ╚═════╝╚═╝ ╚═╝╚══════╝
28
+ """
29
+
30
+ FACE = """
31
+ **#*-
32
+ #*-. .::---:::.
33
+ = .:+#%@@@@@@@@@%#*+-.
34
+ .-*%@@@@@@@@@*==----+*-
35
+ .=-====*%@@@@+:..:===--+:
36
+ . .-:. .=%@%+=-+:...=+==
37
+ .=:-.:=:.+#***+***+++**+.
38
+ -.:++**+-#@%##%%####%##*-
39
+ .=+**#%#-:-+=--+%%@@####+.
40
+ .+*#%@@*...=+::+##%%#***-.
41
+ -*##%@*=:-#*=-:--==##*+:.
42
+ .=+#+-::--::-:.:.: :++=:.
43
+ :++...::::---==++:---...
44
+ :..=**+++-=*#%@%=:::..
45
+ .:-*%@@@@@@@%#*. :::
46
+ : . . -+**#*=-.
47
+ ++-. .::
48
+ +*+=: .-===.
49
+ """
50
+
51
+ # What the face gets on its left, so it lines up with the two-space indent the
52
+ # wordmark and every subtitle carry.
53
+ INDENT = " "
54
+ # Columns between the face and the block beside it. The wordmark lines start
55
+ # with two spaces of their own, so the visible gap is wider than this.
56
+ GAP = 1
57
+
58
+
59
+ LOGO_LINES: tuple[str, ...] = tuple(LOGO.strip("\n").splitlines())
60
+ FACE_LINES: tuple[str, ...] = tuple(FACE.strip("\n").splitlines())
61
+
62
+ HEIGHT = len(FACE_LINES)
63
+ WIDTH = len(INDENT) + max(len(line) for line in FACE_LINES)
64
+
65
+
66
+ def _paint(lines: Sequence[str], colors: tui.Palette, indent: str = "") -> list[str]:
67
+ return [f"{colors.bright_magenta}{indent}{line}{colors.reset}" for line in lines]
68
+
69
+
70
+ def wordmark(colors: tui.Palette) -> list[str]:
71
+ """The block letters, coloured the way every screen prints them."""
72
+ return _paint(LOGO_LINES, colors)
73
+
74
+
75
+ def compose(left: Sequence[str], right: Sequence[str], *, gap: int = GAP) -> list[str]:
76
+ """Lay ``right`` beside ``left``, the shorter block centred on the taller."""
77
+ left = list(left)
78
+ right = list(right)
79
+ left_width = max((tui.display_width(line) for line in left), default=0)
80
+ height = max(len(left), len(right))
81
+ left_top = (height - len(left)) // 2
82
+ right_top = (height - len(right)) // 2
83
+
84
+ rows = []
85
+ for index in range(height):
86
+ a = left[index - left_top] if 0 <= index - left_top < len(left) else ""
87
+ b = right[index - right_top] if 0 <= index - right_top < len(right) else ""
88
+ if b:
89
+ a += " " * (left_width + gap - tui.display_width(a))
90
+ rows.append((a + b).rstrip())
91
+ return rows
92
+
93
+
94
+ def fits(right: Sequence[str], columns: int) -> bool:
95
+ """Whether the face and ``right`` sit side by side in ``columns``."""
96
+ right_width = max((tui.display_width(line) for line in right), default=0)
97
+ return WIDTH + GAP + right_width <= columns
98
+
99
+
100
+ def banner(right: Sequence[str], *, colors: tui.Palette, columns: int | None = None) -> list[str]:
101
+ """The face with ``right`` beside it, or ``right`` alone when it would not fit.
102
+
103
+ ``right`` is normally the wordmark plus whatever a screen wants under it (a
104
+ subtitle, the command count). It comes back untouched, indent and all, on a
105
+ terminal too narrow for both, so every caller keeps working at 40 columns.
106
+ The face is indented to line up with the wordmark and painted in its colour.
107
+ """
108
+ if columns is None:
109
+ columns = tui.size()[0]
110
+ if not fits(right, columns):
111
+ return list(right)
112
+ return compose(_paint(FACE_LINES, colors, INDENT), right)
113
+
114
+
115
+ def print_logo(subtitle: str = "") -> None:
116
+ """The face beside the wordmark, with ``subtitle`` under the letters."""
117
+ colors = tui.palette()
118
+ right = wordmark(colors)
119
+ if subtitle:
120
+ right.append(f" {colors.grey}{subtitle}{colors.reset}")
121
+ for line in banner(right, colors=colors):
122
+ print(line)
123
+ print()