@codebend3r/che 1.0.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.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""📤 Eject all NAS volumes from /Volumes.
|
|
3
|
+
|
|
4
|
+
Checks the primary mount as well as the duplicate ``<name>-1`` and IP-named
|
|
5
|
+
mounts macOS creates when it re-mounts the same share twice. Stale placeholder
|
|
6
|
+
directories left behind by a force-unmount are cleaned up with rmdir (sudo as a
|
|
7
|
+
fallback).
|
|
8
|
+
|
|
9
|
+
Also resets Finder's Favorite Servers and Recent Hosts lists, otherwise ejected
|
|
10
|
+
NAS shares keep reappearing in the sidebar Locations after Finder relaunches.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
eject-all-drives.py [--dry-run] [--only=NAME] [--no-force]
|
|
14
|
+
[--no-clear-favorites] [--quiet]
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--dry-run Show what would be unmounted, change nothing
|
|
18
|
+
--only=NAME Only eject this drive (case-insensitive); skips the
|
|
19
|
+
Favorite Servers reset, which would be over-broad
|
|
20
|
+
--no-force Use 'diskutil unmount' instead of 'diskutil unmount force'
|
|
21
|
+
--no-clear-favorites Don't reset Finder's Favorite Servers / Recent Hosts
|
|
22
|
+
--quiet Only log unmounts and failures
|
|
23
|
+
--help Show help
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
32
|
+
|
|
33
|
+
from utils import (
|
|
34
|
+
add_bool_flag,
|
|
35
|
+
build_parser,
|
|
36
|
+
info,
|
|
37
|
+
log,
|
|
38
|
+
note,
|
|
39
|
+
require_binary,
|
|
40
|
+
run,
|
|
41
|
+
run_cli,
|
|
42
|
+
success,
|
|
43
|
+
warning,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "3.3.0"
|
|
47
|
+
|
|
48
|
+
DRIVES: tuple[tuple[str, str], ...] = (
|
|
49
|
+
("Meleys", "192.168.50.2"),
|
|
50
|
+
("Vermithor", "192.168.50.3"),
|
|
51
|
+
("Caraxes", "192.168.50.4"),
|
|
52
|
+
("Syrax", "192.168.50.5"),
|
|
53
|
+
("Vhagar", "192.168.50.6"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def eject_volume(label: str, *, dry_run: bool, force: bool, quiet: bool) -> str:
|
|
58
|
+
"""Unmount ``/Volumes/<label>``. Returns "ejected", "skipped" or "failed"."""
|
|
59
|
+
mount = Path("/Volumes") / label
|
|
60
|
+
|
|
61
|
+
if not mount.is_dir():
|
|
62
|
+
if not quiet:
|
|
63
|
+
info(f" ⏭️ {label} — not mounted")
|
|
64
|
+
return "skipped"
|
|
65
|
+
|
|
66
|
+
if dry_run:
|
|
67
|
+
warning(f" 🪄 Would unmount {mount}")
|
|
68
|
+
return "ejected"
|
|
69
|
+
|
|
70
|
+
unmount_cmd = ["diskutil", "unmount"]
|
|
71
|
+
if force:
|
|
72
|
+
unmount_cmd.append("force")
|
|
73
|
+
|
|
74
|
+
log(f" 📤 Unmounting {mount}...")
|
|
75
|
+
completed = run([*unmount_cmd, str(mount)], check=False, capture=True)
|
|
76
|
+
if completed.returncode != 0:
|
|
77
|
+
warning(f" ❌ Failed to unmount {mount}")
|
|
78
|
+
return "failed"
|
|
79
|
+
|
|
80
|
+
# A force-unmount can leave the placeholder directory behind. Try to clear
|
|
81
|
+
# it unprivileged first; fall back to sudo, and give up quietly if neither
|
|
82
|
+
# works, exactly as the shell version did.
|
|
83
|
+
if mount.is_dir():
|
|
84
|
+
try:
|
|
85
|
+
mount.rmdir()
|
|
86
|
+
except OSError:
|
|
87
|
+
run(["sudo", "rmdir", str(mount)], check=False, capture=True)
|
|
88
|
+
|
|
89
|
+
success(f" ✅ {label} ejected")
|
|
90
|
+
return "ejected"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reset_finder_state(
|
|
94
|
+
selected: list[tuple[str, str]], *, clear_favorites: bool, only: str
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Drop Finder's server-level entries so the sidebar stops resurrecting them."""
|
|
97
|
+
log("🪪 Asking Finder to drop any server-level entries...")
|
|
98
|
+
for name, _ in selected:
|
|
99
|
+
run(
|
|
100
|
+
[
|
|
101
|
+
"osascript",
|
|
102
|
+
"-e",
|
|
103
|
+
'tell application "Finder" to try',
|
|
104
|
+
"-e",
|
|
105
|
+
f'eject disk "{name}"',
|
|
106
|
+
"-e",
|
|
107
|
+
"end try",
|
|
108
|
+
],
|
|
109
|
+
check=False,
|
|
110
|
+
capture=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Skipped when --only is set: resetlist is all-or-nothing and would nuke
|
|
114
|
+
# the other drives' favorites too.
|
|
115
|
+
if not only and clear_favorites:
|
|
116
|
+
log("🧽 Resetting Finder Favorite Servers + Recent Hosts lists...")
|
|
117
|
+
for list_name in (
|
|
118
|
+
"com.apple.LSSharedFileList.FavoriteServers",
|
|
119
|
+
"com.apple.LSSharedFileList.RecentHosts",
|
|
120
|
+
):
|
|
121
|
+
run(["sfltool", "resetlist", list_name], check=False, capture=True)
|
|
122
|
+
|
|
123
|
+
# SIGKILL specifically: a graceful quit lets Finder save its current
|
|
124
|
+
# sidebar state on the way out and restore the stale entries on relaunch.
|
|
125
|
+
log("🔄 Hard-killing Finder so it doesn't restore stale sidebar state...")
|
|
126
|
+
run(["killall", "-KILL", "Finder"], check=False, capture=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: list[str] | None = None) -> int:
|
|
130
|
+
parser = build_parser(
|
|
131
|
+
prog="eject-all-drives",
|
|
132
|
+
description="📤 Eject all NAS volumes from /Volumes.",
|
|
133
|
+
)
|
|
134
|
+
add_bool_flag(parser, "--dry-run", help="Show what would be unmounted, change nothing")
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--only", default="", metavar="NAME", help="Only eject this drive (case-insensitive)"
|
|
137
|
+
)
|
|
138
|
+
add_bool_flag(
|
|
139
|
+
parser,
|
|
140
|
+
"--no-force",
|
|
141
|
+
dest="no_force",
|
|
142
|
+
allow_value=False,
|
|
143
|
+
help="Use 'diskutil unmount' instead of 'diskutil unmount force'",
|
|
144
|
+
)
|
|
145
|
+
add_bool_flag(
|
|
146
|
+
parser,
|
|
147
|
+
"--no-clear-favorites",
|
|
148
|
+
dest="no_clear_favorites",
|
|
149
|
+
allow_value=False,
|
|
150
|
+
help="Don't reset Finder's Favorite Servers / Recent Hosts lists",
|
|
151
|
+
)
|
|
152
|
+
add_bool_flag(parser, "--quiet", help="Only log unmounts and failures")
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
|
|
155
|
+
force = not args.no_force
|
|
156
|
+
clear_favorites = not args.no_clear_favorites
|
|
157
|
+
|
|
158
|
+
# Only gate on diskutil for a real run: the shell version's dry-run never
|
|
159
|
+
# invoked it, so previewing worked anywhere.
|
|
160
|
+
if not args.dry_run:
|
|
161
|
+
require_binary("diskutil", hint="This script is macOS-only.")
|
|
162
|
+
|
|
163
|
+
header_suffix = " 🌵 (dry run)" if args.dry_run else ""
|
|
164
|
+
note("═══════════════════════════════════════════")
|
|
165
|
+
note(f"📤 eject-all-drives{header_suffix}")
|
|
166
|
+
note("═══════════════════════════════════════════")
|
|
167
|
+
|
|
168
|
+
selected = [(n, ip) for n, ip in DRIVES if not args.only or n.lower() == args.only.lower()]
|
|
169
|
+
if args.only and not selected:
|
|
170
|
+
# Warn, but keep the shell version's exit code and control flow: an
|
|
171
|
+
# unknown --only printed a 0/0 summary and still exited 0.
|
|
172
|
+
warning(f"⚠️ No drive named '{args.only}'. Known: {', '.join(n for n, _ in DRIVES)}")
|
|
173
|
+
|
|
174
|
+
counts = {"ejected": 0, "skipped": 0, "failed": 0}
|
|
175
|
+
|
|
176
|
+
for name, ip in selected:
|
|
177
|
+
info(f"🐉 {name} ({ip})")
|
|
178
|
+
# macOS re-mounts of the same share land at "<name>-1" or at the IP.
|
|
179
|
+
for label in (name, f"{name}-1", ip):
|
|
180
|
+
counts[eject_volume(label, dry_run=args.dry_run, force=force, quiet=args.quiet)] += 1
|
|
181
|
+
|
|
182
|
+
print()
|
|
183
|
+
note("─────────────────────────────────────────────")
|
|
184
|
+
|
|
185
|
+
if args.dry_run:
|
|
186
|
+
success(f"🌵 Would eject: {counts['ejected']}")
|
|
187
|
+
info(f"⏭️ Skipped: {counts['skipped']} (not mounted)")
|
|
188
|
+
log("💡 Dry run complete. Nothing was actually ejected.")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
success(f"✅ Ejected: {counts['ejected']}")
|
|
192
|
+
info(f"⏭️ Skipped: {counts['skipped']} (not mounted)")
|
|
193
|
+
if counts["failed"]:
|
|
194
|
+
warning(f"❌ Failed: {counts['failed']}")
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
reset_finder_state(selected, clear_favorites=clear_favorites, only=args.only)
|
|
198
|
+
log("🎉 All clean! 🧹✨")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
run_cli(main)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""💿 Mount all NAS drives over SMB via AppleScript's ``mount volume``.
|
|
3
|
+
|
|
4
|
+
Using AppleScript (rather than ``mount_smbfs``) takes the same NetAuth path
|
|
5
|
+
Finder does, so ``/Volumes/<name>`` is created automatically and Keychain
|
|
6
|
+
credentials are used silently, with no Finder modals. Already-mounted drives
|
|
7
|
+
are skipped. The share name is assumed to match the drive name, e.g.
|
|
8
|
+
``//crivas@Meleys/Meleys`` → ``/Volumes/Meleys``.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
mount-all-drives.py [--only=NAME] [--use-ip] [--quiet]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--only=NAME Only mount the drive with this name (case-insensitive)
|
|
15
|
+
--use-ip Mount via //USER@IP/NAME instead of //USER@NAME/NAME
|
|
16
|
+
--quiet Only log mounts and failures
|
|
17
|
+
--help Show help
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
27
|
+
|
|
28
|
+
from utils import (
|
|
29
|
+
add_bool_flag,
|
|
30
|
+
build_parser,
|
|
31
|
+
info,
|
|
32
|
+
log,
|
|
33
|
+
note,
|
|
34
|
+
require_binary,
|
|
35
|
+
run,
|
|
36
|
+
run_cli,
|
|
37
|
+
success,
|
|
38
|
+
warning,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "4.1.0"
|
|
42
|
+
|
|
43
|
+
SMB_USER = "crivas"
|
|
44
|
+
|
|
45
|
+
# (name, ip) - the share name is assumed to match the drive name.
|
|
46
|
+
DRIVES: tuple[tuple[str, str], ...] = (
|
|
47
|
+
("Meleys", "192.168.50.2"),
|
|
48
|
+
("Vermithor", "192.168.50.3"),
|
|
49
|
+
("Caraxes", "192.168.50.4"),
|
|
50
|
+
("Syrax", "192.168.50.5"),
|
|
51
|
+
("Vhagar", "192.168.50.6"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_mounted(name: str) -> bool:
|
|
56
|
+
"""True when something is actually mounted at ``/Volumes/<name>``.
|
|
57
|
+
|
|
58
|
+
The shell version grepped ``mount`` output for `` on /Volumes/<name> (``;
|
|
59
|
+
``os.path.ismount`` asks the kernel directly. The two agree for every
|
|
60
|
+
healthy mount. The one real difference: ``ismount`` lstats the mount root,
|
|
61
|
+
so on a wedged SMB mount it can block for the server timeout where reading
|
|
62
|
+
the mount table cannot.
|
|
63
|
+
"""
|
|
64
|
+
return os.path.ismount(f"/Volumes/{name}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mount_drive(name: str, host: str, *, quiet: bool) -> str:
|
|
68
|
+
"""Mount one drive. Returns "mounted", "skipped" or "failed"."""
|
|
69
|
+
if is_mounted(name):
|
|
70
|
+
if not quiet:
|
|
71
|
+
info(f"⏭️ {name} — already mounted")
|
|
72
|
+
return "skipped"
|
|
73
|
+
|
|
74
|
+
url = f"smb://{SMB_USER}@{host}/{name}"
|
|
75
|
+
log(f"🔗 Mounting {name} ({url})...")
|
|
76
|
+
|
|
77
|
+
completed = run(
|
|
78
|
+
["osascript", "-e", f'mount volume "{url}"'],
|
|
79
|
+
check=False,
|
|
80
|
+
capture=True,
|
|
81
|
+
)
|
|
82
|
+
if completed.returncode == 0:
|
|
83
|
+
success(f" ✅ {name} mounted 🐉")
|
|
84
|
+
return "mounted"
|
|
85
|
+
|
|
86
|
+
warning(f" ❌ mount failed for {url}")
|
|
87
|
+
stderr = (completed.stderr or "").strip()
|
|
88
|
+
if stderr:
|
|
89
|
+
warning(f" {stderr}")
|
|
90
|
+
warning(f" Check that Keychain has a password for '{SMB_USER}@{host}'.")
|
|
91
|
+
return "failed"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: list[str] | None = None) -> int:
|
|
95
|
+
parser = build_parser(
|
|
96
|
+
prog="mount-all-drives",
|
|
97
|
+
description="💿 Mount all NAS drives over SMB via AppleScript.",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--only", default="", metavar="NAME", help="Only mount this drive (case-insensitive)"
|
|
101
|
+
)
|
|
102
|
+
add_bool_flag(parser, "--use-ip", help="Mount via //USER@IP/NAME instead of //USER@NAME/NAME")
|
|
103
|
+
add_bool_flag(parser, "--quiet", help="Only log mounts and failures")
|
|
104
|
+
args = parser.parse_args(argv)
|
|
105
|
+
|
|
106
|
+
require_binary("osascript", hint="This script is macOS-only.")
|
|
107
|
+
|
|
108
|
+
note("═══════════════════════════════════════════")
|
|
109
|
+
note("💿 mount-all-drives")
|
|
110
|
+
note("═══════════════════════════════════════════")
|
|
111
|
+
|
|
112
|
+
counts = {"mounted": 0, "skipped": 0, "failed": 0}
|
|
113
|
+
matched = False
|
|
114
|
+
|
|
115
|
+
for name, ip in DRIVES:
|
|
116
|
+
if args.only and name.lower() != args.only.lower():
|
|
117
|
+
continue
|
|
118
|
+
matched = True
|
|
119
|
+
host = ip if args.use_ip else name
|
|
120
|
+
counts[mount_drive(name, host, quiet=args.quiet)] += 1
|
|
121
|
+
|
|
122
|
+
if args.only and not matched:
|
|
123
|
+
# Warn, but keep the shell version's exit code: an unknown --only
|
|
124
|
+
# printed a 0/0 summary and still exited 0.
|
|
125
|
+
warning(f"⚠️ No drive named '{args.only}'. Known: {', '.join(n for n, _ in DRIVES)}")
|
|
126
|
+
|
|
127
|
+
print()
|
|
128
|
+
note("─────────────────────────────────────────────")
|
|
129
|
+
success(f"✅ Mounted: {counts['mounted']}")
|
|
130
|
+
info(f"⏭️ Skipped: {counts['skipped']} (already mounted)")
|
|
131
|
+
if counts["failed"]:
|
|
132
|
+
warning(f"❌ Failed: {counts['failed']}")
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
log("🎉 All drives are up! 🐉🐉🐉")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
run_cli(main)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""📡 Keep-alive pinger for the NAS drives.
|
|
3
|
+
|
|
4
|
+
Each cycle pings every host and verifies it is still mounted under /Volumes.
|
|
5
|
+
If a drive is reachable but the share has dropped off /Volumes (eject, network
|
|
6
|
+
blip, sleep) it is silently remounted via AppleScript's ``mount volume`` using
|
|
7
|
+
Keychain credentials, with no Finder modals.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
ping-nas.py [--interval=SECONDS] [--ping-timeout=SECONDS] [--only=NAME]
|
|
11
|
+
[--no-remount] [--use-ip] [--once] [--quiet]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--interval=N Seconds between cycles (default: 300)
|
|
15
|
+
--ping-timeout=N Per-host ping timeout in seconds (default: 2)
|
|
16
|
+
--only=NAME Only ping the drive with this name (case-insensitive)
|
|
17
|
+
--no-remount Detect-only: warn about missing mounts, don't remount
|
|
18
|
+
--use-ip Remount via //USER@IP/NAME instead of //USER@NAME/NAME
|
|
19
|
+
--once Run a single cycle and exit (useful from cron)
|
|
20
|
+
--quiet Only log failures, remount attempts, and cycle summary
|
|
21
|
+
--help Show help
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import signal
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from types import FrameType
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
35
|
+
|
|
36
|
+
from utils import (
|
|
37
|
+
add_bool_flag,
|
|
38
|
+
build_parser,
|
|
39
|
+
info,
|
|
40
|
+
log,
|
|
41
|
+
note,
|
|
42
|
+
run,
|
|
43
|
+
run_cli,
|
|
44
|
+
success,
|
|
45
|
+
warning,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__version__ = "3.3.0"
|
|
49
|
+
|
|
50
|
+
SMB_USER = "crivas"
|
|
51
|
+
|
|
52
|
+
DRIVES: tuple[tuple[str, str], ...] = (
|
|
53
|
+
("Meleys", "192.168.50.2"),
|
|
54
|
+
("Vermithor", "192.168.50.3"),
|
|
55
|
+
("Caraxes", "192.168.50.4"),
|
|
56
|
+
("Syrax", "192.168.50.5"),
|
|
57
|
+
("Vhagar", "192.168.50.6"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_cycle = 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_mounted(name: str) -> bool:
|
|
64
|
+
"""True when something is actually mounted at ``/Volumes/<name>``."""
|
|
65
|
+
return os.path.ismount(f"/Volumes/{name}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ping_host(ip: str, timeout: int) -> bool:
|
|
69
|
+
"""One ICMP probe with a per-host timeout. macOS ``ping -t`` is seconds."""
|
|
70
|
+
completed = run(["ping", "-c", "1", "-t", str(timeout), ip], check=False, capture=True)
|
|
71
|
+
return completed.returncode == 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def remount_drive(name: str, host: str) -> bool:
|
|
75
|
+
"""Remount one drive via AppleScript. True on success."""
|
|
76
|
+
url = f"smb://{SMB_USER}@{host}/{name}"
|
|
77
|
+
log(f" 🔗 Remounting {name} ({url})...")
|
|
78
|
+
|
|
79
|
+
completed = run(["osascript", "-e", f'mount volume "{url}"'], check=False, capture=True)
|
|
80
|
+
if completed.returncode == 0:
|
|
81
|
+
success(f" ✅ {name} remounted 🐉")
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
warning(f" ❌ mount failed for {url}")
|
|
85
|
+
stderr = (completed.stderr or "").strip()
|
|
86
|
+
if stderr:
|
|
87
|
+
warning(f" {stderr}")
|
|
88
|
+
warning(f" Check that Keychain has a password for '{SMB_USER}@{host}'.")
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ping_all_nas(
|
|
93
|
+
active: list[tuple[str, str]],
|
|
94
|
+
*,
|
|
95
|
+
ping_timeout: int,
|
|
96
|
+
remount: bool,
|
|
97
|
+
use_ip: bool,
|
|
98
|
+
quiet: bool,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Run one full check cycle over the active drive list."""
|
|
101
|
+
global _cycle
|
|
102
|
+
_cycle += 1
|
|
103
|
+
|
|
104
|
+
unreachable = 0
|
|
105
|
+
unmounted = 0
|
|
106
|
+
remounted = 0
|
|
107
|
+
remount_failed = 0
|
|
108
|
+
|
|
109
|
+
if not quiet:
|
|
110
|
+
log(f"🔄 Cycle #{_cycle} — checking {len(active)} NAS drive(s)...")
|
|
111
|
+
|
|
112
|
+
for name, ip in active:
|
|
113
|
+
if not ping_host(ip, ping_timeout):
|
|
114
|
+
warning(f" ❌ {name} ({ip}) — ping failed")
|
|
115
|
+
unreachable += 1
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if is_mounted(name):
|
|
119
|
+
if not quiet:
|
|
120
|
+
log(f" ✅ 🐉 {name} ({ip}) — mounted")
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
warning(f" ⚠️ {name} ({ip}) — reachable but not mounted")
|
|
124
|
+
unmounted += 1
|
|
125
|
+
|
|
126
|
+
if remount:
|
|
127
|
+
if remount_drive(name, ip if use_ip else name):
|
|
128
|
+
remounted += 1
|
|
129
|
+
else:
|
|
130
|
+
remount_failed += 1
|
|
131
|
+
|
|
132
|
+
if unreachable == 0 and unmounted == 0:
|
|
133
|
+
if not quiet:
|
|
134
|
+
success(f"✨ All {len(active)} drive(s) responded and mounted 🎉")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
parts = []
|
|
138
|
+
if unreachable:
|
|
139
|
+
parts.append(f"{unreachable} unreachable")
|
|
140
|
+
if remounted:
|
|
141
|
+
parts.append(f"{remounted} remounted")
|
|
142
|
+
if remount_failed:
|
|
143
|
+
parts.append(f"{remount_failed} remount-failed")
|
|
144
|
+
if not remount and unmounted:
|
|
145
|
+
parts.append(f"{unmounted} unmounted")
|
|
146
|
+
warning(f"⚠️ Cycle summary: {' '.join(parts)}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _shutdown(signum: int, frame: FrameType | None) -> None:
|
|
150
|
+
"""Exit 0 on Ctrl-C, matching the shell version's INT/TERM trap."""
|
|
151
|
+
print()
|
|
152
|
+
info(f"👋 Stopping keep-alive pinger after {_cycle} cycle(s).")
|
|
153
|
+
sys.exit(0)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main(argv: list[str] | None = None) -> int:
|
|
157
|
+
parser = build_parser(
|
|
158
|
+
prog="ping-nas",
|
|
159
|
+
description="📡 Keep-alive pinger for the NAS drives.",
|
|
160
|
+
epilog=(
|
|
161
|
+
"Each cycle pings every host and verifies it is still mounted under\n"
|
|
162
|
+
"/Volumes. A drive that is reachable but has dropped off /Volumes is\n"
|
|
163
|
+
f"silently remounted via AppleScript as '{SMB_USER}' using Keychain\n"
|
|
164
|
+
"credentials, with no Finder modals."
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
# --interval and --ping-timeout stay strings so the shell version's own
|
|
168
|
+
# message survives; `type=int` would let argparse own the failure first.
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
"--interval", default="300", metavar="N", help="Seconds between cycles (default: 300)"
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--ping-timeout",
|
|
174
|
+
dest="ping_timeout",
|
|
175
|
+
default="2",
|
|
176
|
+
metavar="N",
|
|
177
|
+
help="Per-host ping timeout in seconds (default: 2)",
|
|
178
|
+
)
|
|
179
|
+
parser.add_argument(
|
|
180
|
+
"--only", default="", metavar="NAME", help="Only ping this drive (case-insensitive)"
|
|
181
|
+
)
|
|
182
|
+
add_bool_flag(
|
|
183
|
+
parser,
|
|
184
|
+
"--no-remount",
|
|
185
|
+
dest="no_remount",
|
|
186
|
+
allow_value=False,
|
|
187
|
+
help="Detect-only: warn about missing mounts, don't remount",
|
|
188
|
+
)
|
|
189
|
+
add_bool_flag(
|
|
190
|
+
parser,
|
|
191
|
+
"--use-ip",
|
|
192
|
+
dest="use_ip",
|
|
193
|
+
help="Remount via //USER@IP/NAME instead of //USER@NAME/NAME",
|
|
194
|
+
)
|
|
195
|
+
add_bool_flag(parser, "--once", help="Run a single cycle and exit (useful from cron)")
|
|
196
|
+
add_bool_flag(parser, "--quiet", help="Only log failures, remount attempts, and cycle summary")
|
|
197
|
+
args = parser.parse_args(argv)
|
|
198
|
+
|
|
199
|
+
values: dict[str, int] = {}
|
|
200
|
+
for flag, raw in (("--interval", args.interval), ("--ping-timeout", args.ping_timeout)):
|
|
201
|
+
if not re.fullmatch(r"[0-9]+", raw) or int(raw) < 1:
|
|
202
|
+
warning(f"❌ {flag} must be a positive integer (got: {raw})")
|
|
203
|
+
return 1
|
|
204
|
+
values[flag] = int(raw)
|
|
205
|
+
interval = values["--interval"]
|
|
206
|
+
ping_timeout = values["--ping-timeout"]
|
|
207
|
+
|
|
208
|
+
active = [(n, ip) for n, ip in DRIVES if not args.only or n.lower() == args.only.lower()]
|
|
209
|
+
if not active:
|
|
210
|
+
warning(f"❌ No drives matched --only={args.only}")
|
|
211
|
+
return 1
|
|
212
|
+
|
|
213
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
214
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
215
|
+
|
|
216
|
+
note("═══════════════════════════════════════════")
|
|
217
|
+
note("📡 ping-nas")
|
|
218
|
+
note("═══════════════════════════════════════════")
|
|
219
|
+
|
|
220
|
+
cycle_kwargs = {
|
|
221
|
+
"ping_timeout": ping_timeout,
|
|
222
|
+
"remount": not args.no_remount,
|
|
223
|
+
"use_ip": args.use_ip,
|
|
224
|
+
"quiet": args.quiet,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if args.once:
|
|
228
|
+
ping_all_nas(active, **cycle_kwargs)
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
info(
|
|
232
|
+
f"🚀 Keep-alive pinger started for {len(active)} NAS "
|
|
233
|
+
f"(every {interval}s). Press Ctrl-C to stop."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
while True:
|
|
237
|
+
ping_all_nas(active, **cycle_kwargs)
|
|
238
|
+
time.sleep(interval)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
run_cli(main)
|