devmedic 0.2.0__tar.gz → 0.3.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.
- {devmedic-0.2.0 → devmedic-0.3.0}/PKG-INFO +17 -5
- {devmedic-0.2.0 → devmedic-0.3.0}/README.md +14 -3
- devmedic-0.3.0/devmedic/__init__.py +1 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/caches.py +70 -10
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/cli.py +14 -10
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/dashboard.py +99 -28
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/junk.py +14 -11
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/menu.py +24 -3
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/ports.py +28 -17
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/procs.py +8 -5
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/repos.py +4 -5
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/util.py +72 -8
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/PKG-INFO +17 -5
- {devmedic-0.2.0 → devmedic-0.3.0}/pyproject.toml +2 -1
- {devmedic-0.2.0 → devmedic-0.3.0}/tests/test_devmedic.py +76 -22
- devmedic-0.2.0/devmedic/__init__.py +0 -1
- {devmedic-0.2.0 → devmedic-0.3.0}/LICENSE +0 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic/__main__.py +0 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/SOURCES.txt +0 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/dependency_links.txt +0 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/entry_points.txt +0 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/requires.txt +0 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/devmedic.egg-info/top_level.txt +0 -0
- {devmedic-0.2.0 → devmedic-0.3.0}/setup.cfg +0 -0
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devmedic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A health check for your developer machine: ports, junk folders, caches, git repos, dev processes — offline.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/yaswanthme007/devmedic
|
|
7
7
|
Project-URL: Issues, https://github.com/yaswanthme007/devmedic/issues
|
|
8
|
-
Keywords: developer-tools,cli,linux,ubuntu,cleanup,ports,disk-usage
|
|
8
|
+
Keywords: developer-tools,cli,linux,ubuntu,windows,cleanup,ports,disk-usage
|
|
9
9
|
Classifier: Environment :: Console
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
14
|
Classifier: Programming Language :: Python :: 3
|
|
14
15
|
Classifier: Topic :: Software Development
|
|
15
16
|
Classifier: Topic :: System :: Monitoring
|
|
@@ -47,16 +48,24 @@ devmedic answers all of them in seconds.
|
|
|
47
48
|
|
|
48
49
|
## Install
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
On **Windows**, use it inside [WSL](https://learn.microsoft.com/windows/wsl/install) for now; native support is planned.
|
|
51
|
+
Works on **Linux** (built on Ubuntu) and **Windows 10/11**, with **Python 3.9+**.
|
|
52
52
|
|
|
53
53
|
**With pipx (recommended)**
|
|
54
54
|
|
|
55
|
+
Linux:
|
|
56
|
+
|
|
55
57
|
```bash
|
|
56
58
|
sudo apt install pipx && pipx ensurepath # once
|
|
57
59
|
pipx install devmedic
|
|
58
60
|
```
|
|
59
61
|
|
|
62
|
+
Windows (PowerShell or Windows Terminal):
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
py -m pip install --user pipx; py -m pipx ensurepath # once, then open a new terminal
|
|
66
|
+
pipx install devmedic
|
|
67
|
+
```
|
|
68
|
+
|
|
60
69
|
**With pip**
|
|
61
70
|
|
|
62
71
|
```bash
|
|
@@ -115,7 +124,10 @@ devmedic is careful by design:
|
|
|
115
124
|
- **Nothing is deleted without confirmation** (unless you pass `-y`).
|
|
116
125
|
- **Conservative junk detection:** `target/` only next to `Cargo.toml`/`pom.xml`, venvs only if
|
|
117
126
|
they contain `pyvenv.cfg`. Generic `build/` and `dist/` folders are **never** touched.
|
|
118
|
-
- **
|
|
127
|
+
- **Links are never followed:** symlinks and Windows junctions (used by pnpm) are skipped when
|
|
128
|
+
scanning and removed as links, so nothing outside the folder is ever deleted.
|
|
129
|
+
- **Never runs `sudo` or Administrator commands.** System fixes (journal, apt, snap, Recycle Bin,
|
|
130
|
+
docker) are printed for you to copy.
|
|
119
131
|
- **Only kills what you confirm**, and can't touch other users' processes.
|
|
120
132
|
- **Offline and private:** no network requests, no data collection.
|
|
121
133
|
|
|
@@ -25,16 +25,24 @@ devmedic answers all of them in seconds.
|
|
|
25
25
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
On **Windows**, use it inside [WSL](https://learn.microsoft.com/windows/wsl/install) for now; native support is planned.
|
|
28
|
+
Works on **Linux** (built on Ubuntu) and **Windows 10/11**, with **Python 3.9+**.
|
|
30
29
|
|
|
31
30
|
**With pipx (recommended)**
|
|
32
31
|
|
|
32
|
+
Linux:
|
|
33
|
+
|
|
33
34
|
```bash
|
|
34
35
|
sudo apt install pipx && pipx ensurepath # once
|
|
35
36
|
pipx install devmedic
|
|
36
37
|
```
|
|
37
38
|
|
|
39
|
+
Windows (PowerShell or Windows Terminal):
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
py -m pip install --user pipx; py -m pipx ensurepath # once, then open a new terminal
|
|
43
|
+
pipx install devmedic
|
|
44
|
+
```
|
|
45
|
+
|
|
38
46
|
**With pip**
|
|
39
47
|
|
|
40
48
|
```bash
|
|
@@ -93,7 +101,10 @@ devmedic is careful by design:
|
|
|
93
101
|
- **Nothing is deleted without confirmation** (unless you pass `-y`).
|
|
94
102
|
- **Conservative junk detection:** `target/` only next to `Cargo.toml`/`pom.xml`, venvs only if
|
|
95
103
|
they contain `pyvenv.cfg`. Generic `build/` and `dist/` folders are **never** touched.
|
|
96
|
-
- **
|
|
104
|
+
- **Links are never followed:** symlinks and Windows junctions (used by pnpm) are skipped when
|
|
105
|
+
scanning and removed as links, so nothing outside the folder is ever deleted.
|
|
106
|
+
- **Never runs `sudo` or Administrator commands.** System fixes (journal, apt, snap, Recycle Bin,
|
|
107
|
+
docker) are printed for you to copy.
|
|
97
108
|
- **Only kills what you confirm**, and can't touch other users' processes.
|
|
98
109
|
- **Offline and private:** no network requests, no data collection.
|
|
99
110
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -6,13 +6,35 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
6
6
|
|
|
7
7
|
from rich.table import Table
|
|
8
8
|
|
|
9
|
-
from .util import (HOME, confirm, console, dir_size, have, human_size,
|
|
10
|
-
run, short_path, size_style)
|
|
9
|
+
from .util import (HOME, WINDOWS, confirm, console, dir_size, have, human_size,
|
|
10
|
+
is_real_dir, remove_tree, run, run_full, short_path, size_style)
|
|
11
11
|
|
|
12
12
|
h = lambda *p: os.path.join(HOME, *p)
|
|
13
|
+
LOCAL = os.environ.get("LOCALAPPDATA") or h("AppData", "Local")
|
|
14
|
+
ROAMING = os.environ.get("APPDATA") or h("AppData", "Roaming")
|
|
15
|
+
lo = lambda *p: os.path.join(LOCAL, *p)
|
|
16
|
+
ro = lambda *p: os.path.join(ROAMING, *p)
|
|
13
17
|
|
|
14
18
|
# key, label, path, clean command (None -> delete dir contents), note
|
|
15
|
-
|
|
19
|
+
WINDOWS_CACHES = [
|
|
20
|
+
("pip", "pip cache", lo("pip", "Cache"), ["pip", "cache", "purge"], ""),
|
|
21
|
+
("uv", "uv cache", lo("uv", "cache"), ["uv", "cache", "clean"], ""),
|
|
22
|
+
("npm", "npm cache", lo("npm-cache", "_cacache"), ["npm", "cache", "clean", "--force"], ""),
|
|
23
|
+
("yarn", "yarn cache", lo("Yarn", "Cache"), ["yarn", "cache", "clean"], ""),
|
|
24
|
+
("pnpm", "pnpm store", lo("pnpm", "store"), ["pnpm", "store", "prune"], "prunes unused only"),
|
|
25
|
+
("cargo", "cargo registry", h(".cargo", "registry"), None, ""),
|
|
26
|
+
("go", "Go module cache", h("go", "pkg", "mod"), ["go", "clean", "-modcache"], ""),
|
|
27
|
+
("gobuild", "Go build cache", lo("go-build"), ["go", "clean", "-cache"], ""),
|
|
28
|
+
("gradle", "Gradle caches", h(".gradle", "caches"), None, ""),
|
|
29
|
+
("maven", "Maven repo", h(".m2", "repository"), None, ""),
|
|
30
|
+
("nuget", "NuGet packages", h(".nuget", "packages"), ["dotnet", "nuget", "locals", "all", "--clear"], ""),
|
|
31
|
+
("hf", "HuggingFace models", h(".cache", "huggingface"), None, "re-download is big!"),
|
|
32
|
+
("temp", "Temp files", os.environ.get("TEMP") or lo("Temp"), None, "files in use are skipped"),
|
|
33
|
+
("vscode", "VS Code cache", ro("Code", "Cache"), None, "close VS Code first"),
|
|
34
|
+
("chrome", "Chrome cache", lo("Google", "Chrome", "User Data", "Default", "Cache"), None, "close Chrome first"),
|
|
35
|
+
("edge", "Edge cache", lo("Microsoft", "Edge", "User Data", "Default", "Cache"), None, "close Edge first"),
|
|
36
|
+
]
|
|
37
|
+
LINUX_CACHES = [
|
|
16
38
|
("pip", "pip cache", h(".cache", "pip"), ["pip", "cache", "purge"], ""),
|
|
17
39
|
("uv", "uv cache", h(".cache", "uv"), ["uv", "cache", "clean"], ""),
|
|
18
40
|
("npm", "npm cache", h(".npm", "_cacache"), ["npm", "cache", "clean", "--force"], ""),
|
|
@@ -30,6 +52,28 @@ CACHES = [
|
|
|
30
52
|
("chrome", "Chrome cache", h(".cache", "google-chrome"), None, "close Chrome first"),
|
|
31
53
|
("firefox", "Firefox cache", h(".cache", "mozilla"), None, "close Firefox first"),
|
|
32
54
|
]
|
|
55
|
+
CACHES = WINDOWS_CACHES if WINDOWS else LINUX_CACHES
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def recycle_bin_usage():
|
|
59
|
+
"""Bytes in the Windows Recycle Bin (all drives), or None."""
|
|
60
|
+
if not WINDOWS:
|
|
61
|
+
return None
|
|
62
|
+
import ctypes
|
|
63
|
+
from ctypes import wintypes
|
|
64
|
+
|
|
65
|
+
class SHQUERYRBINFO(ctypes.Structure):
|
|
66
|
+
_fields_ = [("cbSize", wintypes.DWORD), ("i64Size", ctypes.c_longlong),
|
|
67
|
+
("i64NumItems", ctypes.c_longlong)]
|
|
68
|
+
|
|
69
|
+
info = SHQUERYRBINFO()
|
|
70
|
+
info.cbSize = ctypes.sizeof(info)
|
|
71
|
+
try:
|
|
72
|
+
if ctypes.windll.shell32.SHQueryRecycleBinW(None, ctypes.byref(info)) != 0:
|
|
73
|
+
return None
|
|
74
|
+
except (AttributeError, OSError):
|
|
75
|
+
return None
|
|
76
|
+
return info.i64Size or None
|
|
33
77
|
|
|
34
78
|
|
|
35
79
|
def journal_usage():
|
|
@@ -85,6 +129,7 @@ def collect():
|
|
|
85
129
|
sizes = list(pool.map(dir_size, [c[2] for c in present]))
|
|
86
130
|
j, a, d = pool.submit(journal_usage), pool.submit(apt_usage), pool.submit(docker_usage)
|
|
87
131
|
snaps = pool.submit(snap_disabled)
|
|
132
|
+
rbin = pool.submit(recycle_bin_usage)
|
|
88
133
|
user = [{"key": c[0], "label": c[1], "path": c[2], "cmd": c[3], "note": c[4],
|
|
89
134
|
"size": s} for c, s in zip(present, sizes) if s > 0]
|
|
90
135
|
user.sort(key=lambda r: r["size"], reverse=True)
|
|
@@ -95,6 +140,9 @@ def collect():
|
|
|
95
140
|
if a.result():
|
|
96
141
|
system.append({"key": "apt", "label": "apt package cache", "size": a.result(),
|
|
97
142
|
"fix": "sudo apt clean && sudo apt autoremove --purge"})
|
|
143
|
+
if rbin.result():
|
|
144
|
+
system.append({"key": "recyclebin", "label": "Recycle Bin", "size": rbin.result(),
|
|
145
|
+
"fix": "Clear-RecycleBin -Force (PowerShell, permanent!)"})
|
|
98
146
|
if d.result():
|
|
99
147
|
system.append({"key": "docker", "label": "Docker (reclaimable)", "size": d.result(),
|
|
100
148
|
"fix": "docker system prune (add -a --volumes for everything)"})
|
|
@@ -120,7 +168,8 @@ def show(user, system):
|
|
|
120
168
|
console.print(f"[bold]{human_size(total)}[/] in user caches. "
|
|
121
169
|
f"Clean with: [bold]devmedic caches --clean pip npm …[/] or [bold]--clean all[/]")
|
|
122
170
|
if system:
|
|
123
|
-
|
|
171
|
+
needs = "Administrator / PowerShell" if WINDOWS else "sudo / docker"
|
|
172
|
+
s = Table(title=f"System (needs {needs})", title_justify="left")
|
|
124
173
|
s.add_column("Size", justify="right")
|
|
125
174
|
s.add_column("What")
|
|
126
175
|
s.add_column("Fix command", style="bold", overflow="fold")
|
|
@@ -128,7 +177,8 @@ def show(user, system):
|
|
|
128
177
|
size = f"[{size_style(r['size'])}]{human_size(r['size'])}[/]" if r["size"] else "—"
|
|
129
178
|
s.add_row(size, r["label"], r["fix"])
|
|
130
179
|
console.print(s)
|
|
131
|
-
console.print("[dim]devmedic never runs
|
|
180
|
+
console.print("[dim]devmedic never runs system-wide commands itself — "
|
|
181
|
+
"copy the command if you want it.[/]")
|
|
132
182
|
|
|
133
183
|
|
|
134
184
|
def clean(user, keys, assume_yes=False):
|
|
@@ -154,20 +204,30 @@ def clean(user, keys, assume_yes=False):
|
|
|
154
204
|
ok = False
|
|
155
205
|
if r["cmd"] and have(r["cmd"][0]):
|
|
156
206
|
try:
|
|
157
|
-
ok =
|
|
207
|
+
ok = run_full(r["cmd"], timeout=300).returncode == 0
|
|
158
208
|
except (OSError, subprocess.SubprocessError):
|
|
159
209
|
ok = False
|
|
160
210
|
if not ok:
|
|
161
|
-
|
|
162
|
-
|
|
211
|
+
# Try every entry (a list, not a generator: one locked file must not
|
|
212
|
+
# stop the rest from being cleaned).
|
|
213
|
+
try:
|
|
214
|
+
entries = list(os.scandir(r["path"]))
|
|
215
|
+
except OSError:
|
|
216
|
+
entries = []
|
|
217
|
+
ok = all([_remove_entry(e) for e in entries])
|
|
163
218
|
after = dir_size(r["path"]) if os.path.isdir(r["path"]) else 0
|
|
164
219
|
mark = "[green]✔[/]" if ok else "[yellow]~[/]"
|
|
165
220
|
console.print(f"{mark} {r['label']}: freed {human_size(max(r['size'] - after, 0))}")
|
|
166
221
|
|
|
167
222
|
|
|
168
|
-
def
|
|
223
|
+
def _remove_entry(entry):
|
|
224
|
+
if is_real_dir(entry):
|
|
225
|
+
return remove_tree(entry.path)
|
|
169
226
|
try:
|
|
170
|
-
|
|
227
|
+
if entry.is_dir(follow_symlinks=False):
|
|
228
|
+
os.rmdir(entry.path) # Windows junction: remove the link, never the target
|
|
229
|
+
else:
|
|
230
|
+
os.unlink(entry.path)
|
|
171
231
|
return True
|
|
172
232
|
except OSError:
|
|
173
233
|
return False
|
|
@@ -124,21 +124,25 @@ def build_parser():
|
|
|
124
124
|
return p
|
|
125
125
|
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
def utf8_output():
|
|
128
|
+
"""Windows pipes/redirects default to a legacy code page that can't print
|
|
129
|
+
emoji or box-drawing characters; switch to UTF-8 instead of crashing."""
|
|
130
|
+
for stream in (sys.stdout, sys.stderr):
|
|
131
|
+
enc = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
|
|
132
|
+
if enc != "utf8" and hasattr(stream, "reconfigure"):
|
|
133
|
+
try:
|
|
134
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
135
|
+
except (OSError, ValueError):
|
|
136
|
+
pass
|
|
128
137
|
|
|
129
138
|
|
|
130
139
|
def main(argv=None):
|
|
131
140
|
args = build_parser().parse_args(argv)
|
|
132
141
|
if sys.platform == "win32":
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"
|
|
137
|
-
"Native Windows support is coming in a future release.")
|
|
138
|
-
return 1
|
|
139
|
-
if not sys.platform.startswith("linux"):
|
|
140
|
-
console.print("[yellow]devmedic is built for Linux; some checks may be "
|
|
141
|
-
"missing on this system.[/]")
|
|
142
|
+
utf8_output()
|
|
143
|
+
elif not sys.platform.startswith("linux"):
|
|
144
|
+
console.print("[yellow]devmedic supports Linux and Windows; on this system "
|
|
145
|
+
"some checks may be missing.[/]")
|
|
142
146
|
if not getattr(args, "func", None):
|
|
143
147
|
args.func = cmd_overview
|
|
144
148
|
try:
|
|
@@ -18,8 +18,8 @@ from rich.table import Table
|
|
|
18
18
|
from rich.text import Text
|
|
19
19
|
|
|
20
20
|
from . import caches, junk, ports, procs, repos
|
|
21
|
-
from .util import (HOME, console, dir_size, have, human_age, human_size, run,
|
|
22
|
-
short_path)
|
|
21
|
+
from .util import (HOME, WINDOWS, console, dir_size, have, human_age, human_size, run,
|
|
22
|
+
run_full, short_path)
|
|
23
23
|
|
|
24
24
|
# ───────────────────────── visuals ─────────────────────────
|
|
25
25
|
|
|
@@ -93,7 +93,22 @@ def panel(body, title, color):
|
|
|
93
93
|
|
|
94
94
|
# ───────────────────────── collectors ─────────────────────────
|
|
95
95
|
|
|
96
|
+
def windows_version():
|
|
97
|
+
"""'Windows 11 Pro' — platform.release() says '10' on Windows 11, so use the build."""
|
|
98
|
+
ver = platform.version() # e.g. 10.0.22631
|
|
99
|
+
try:
|
|
100
|
+
build = int(ver.split(".")[2])
|
|
101
|
+
except (IndexError, ValueError):
|
|
102
|
+
build = 0
|
|
103
|
+
name = "11" if build >= 22000 else platform.release()
|
|
104
|
+
edition = getattr(platform, "win32_edition", lambda: "")() or ""
|
|
105
|
+
edition = {"Professional": "Pro", "Core": "Home"}.get(edition, edition)
|
|
106
|
+
return f"Windows {name} {edition}".strip()
|
|
107
|
+
|
|
108
|
+
|
|
96
109
|
def os_name():
|
|
110
|
+
if WINDOWS:
|
|
111
|
+
return windows_version()
|
|
97
112
|
try:
|
|
98
113
|
with open("/etc/os-release") as f:
|
|
99
114
|
for line in f:
|
|
@@ -104,14 +119,25 @@ def os_name():
|
|
|
104
119
|
return platform.system()
|
|
105
120
|
|
|
106
121
|
|
|
122
|
+
def tidy_cpu(name):
|
|
123
|
+
name = re.sub(r"\((R|TM)\)|CPU|Processor|\s@.*|\d+-Core", "", name)
|
|
124
|
+
return " ".join(name.split())
|
|
125
|
+
|
|
126
|
+
|
|
107
127
|
def cpu_model():
|
|
128
|
+
if WINDOWS:
|
|
129
|
+
try:
|
|
130
|
+
import winreg
|
|
131
|
+
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
|
132
|
+
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as k:
|
|
133
|
+
return tidy_cpu(winreg.QueryValueEx(k, "ProcessorNameString")[0])
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
108
136
|
try:
|
|
109
137
|
with open("/proc/cpuinfo") as f:
|
|
110
138
|
for line in f:
|
|
111
139
|
if line.startswith("model name"):
|
|
112
|
-
|
|
113
|
-
name = re.sub(r"\((R|TM)\)|CPU|Processor|\s@.*", "", name)
|
|
114
|
-
return " ".join(name.split())
|
|
140
|
+
return tidy_cpu(line.split(":", 1)[1])
|
|
115
141
|
except OSError:
|
|
116
142
|
pass
|
|
117
143
|
return platform.processor() or "unknown CPU"
|
|
@@ -136,15 +162,32 @@ def battery():
|
|
|
136
162
|
return None
|
|
137
163
|
|
|
138
164
|
|
|
165
|
+
def load_average():
|
|
166
|
+
try:
|
|
167
|
+
return psutil.getloadavg() # emulated on Windows (zeros on the first call)
|
|
168
|
+
except (AttributeError, OSError):
|
|
169
|
+
return (0.0, 0.0, 0.0)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def shell_name():
|
|
173
|
+
"""The shell devmedic was started from (bash, zsh, pwsh, cmd…)."""
|
|
174
|
+
try:
|
|
175
|
+
name = psutil.Process(os.getppid()).name()
|
|
176
|
+
except psutil.Error:
|
|
177
|
+
name = os.environ.get("SHELL", "")
|
|
178
|
+
name = os.path.splitext(os.path.basename(name))[0]
|
|
179
|
+
return {"powershell": "Windows PowerShell"}.get(name.lower(), name)
|
|
180
|
+
|
|
181
|
+
|
|
139
182
|
def system_info():
|
|
140
183
|
return {
|
|
141
184
|
"user": getpass.getuser(), "host": socket.gethostname(), "os": os_name(),
|
|
142
|
-
"kernel": platform.release(), "cpu": cpu_model(),
|
|
185
|
+
"kernel": platform.version() if WINDOWS else platform.release(), "cpu": cpu_model(),
|
|
143
186
|
"cores": psutil.cpu_count(logical=True),
|
|
144
187
|
"phys": psutil.cpu_count(logical=False),
|
|
145
|
-
"boot": psutil.boot_time(), "load":
|
|
188
|
+
"boot": psutil.boot_time(), "load": load_average(),
|
|
146
189
|
"temp": temperature(), "battery": battery(),
|
|
147
|
-
"shell":
|
|
190
|
+
"shell": shell_name(),
|
|
148
191
|
}
|
|
149
192
|
|
|
150
193
|
|
|
@@ -155,8 +198,10 @@ def memory_hogs(limit=5):
|
|
|
155
198
|
if mi and p.info["name"]:
|
|
156
199
|
name = p.info["name"]
|
|
157
200
|
# Group chrome/chromium helpers, "Web Content", etc. under the app.
|
|
158
|
-
name
|
|
159
|
-
|
|
201
|
+
if name.lower().endswith(".exe"):
|
|
202
|
+
name = name[:-4]
|
|
203
|
+
name = re.sub(r"^(chrome|chromium|firefox|code|electron|brave|msedge)\b.*", r"\1",
|
|
204
|
+
name, flags=re.I)
|
|
160
205
|
by_name[name][0] += mi.rss
|
|
161
206
|
by_name[name][1] += 1
|
|
162
207
|
return sorted(((n, rss, c) for n, (rss, c) in by_name.items()),
|
|
@@ -166,7 +211,9 @@ def memory_hogs(limit=5):
|
|
|
166
211
|
def disks():
|
|
167
212
|
seen, out = set(), []
|
|
168
213
|
for p in psutil.disk_partitions(all=False):
|
|
169
|
-
if p.fstype in ("squashfs", "tmpfs", "overlay") or p.device in seen:
|
|
214
|
+
if p.fstype in ("squashfs", "tmpfs", "overlay", "") or p.device in seen:
|
|
215
|
+
continue # "" = empty CD/DVD drive on Windows
|
|
216
|
+
if "cdrom" in p.opts:
|
|
170
217
|
continue
|
|
171
218
|
if p.mountpoint.startswith(("/snap", "/var/snap", "/run")):
|
|
172
219
|
continue
|
|
@@ -181,7 +228,8 @@ def disks():
|
|
|
181
228
|
|
|
182
229
|
|
|
183
230
|
TOOLS = [ # label, version command
|
|
184
|
-
("git", ["git", "--version"]),
|
|
231
|
+
("git", ["git", "--version"]),
|
|
232
|
+
("python", ["python", "--version"] if WINDOWS else ["python3", "--version"]),
|
|
185
233
|
("pip", ["pip3", "--version"]), ("node", ["node", "--version"]),
|
|
186
234
|
("npm", ["npm", "--version"]), ("pnpm", ["pnpm", "--version"]),
|
|
187
235
|
("yarn", ["yarn", "--version"]), ("bun", ["bun", "--version"]),
|
|
@@ -192,6 +240,10 @@ TOOLS = [ # label, version command
|
|
|
192
240
|
("gh", ["gh", "--version"]), ("code", ["code", "--version"]),
|
|
193
241
|
("nvim", ["nvim", "--version"]), ("vim", ["vim", "--version"]),
|
|
194
242
|
]
|
|
243
|
+
if WINDOWS:
|
|
244
|
+
TOOLS = [t for t in TOOLS if t[0] not in ("gcc", "make", "vim")] + [
|
|
245
|
+
("pwsh", ["pwsh", "--version"]), ("winget", ["winget", "--version"]),
|
|
246
|
+
("wsl", ["wsl", "--version"]), ("dotnet", ["dotnet", "--version"])]
|
|
195
247
|
VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?)")
|
|
196
248
|
|
|
197
249
|
|
|
@@ -199,11 +251,13 @@ def tool_version(cmd):
|
|
|
199
251
|
if not have(cmd[0]):
|
|
200
252
|
return None
|
|
201
253
|
try:
|
|
202
|
-
r =
|
|
254
|
+
r = run_full(cmd, timeout=4)
|
|
203
255
|
except (OSError, subprocess.SubprocessError):
|
|
204
256
|
return "?"
|
|
205
|
-
|
|
206
|
-
|
|
257
|
+
# No version in the output = not really installed (e.g. Windows' "python"
|
|
258
|
+
# shortcut that just opens the Microsoft Store).
|
|
259
|
+
m = VERSION_RE.search((r.stdout + r.stderr).replace("\x00", ""))
|
|
260
|
+
return m.group(1) if m else None
|
|
207
261
|
|
|
208
262
|
|
|
209
263
|
def dev_tools():
|
|
@@ -216,8 +270,8 @@ def docker_status():
|
|
|
216
270
|
if not have("docker"):
|
|
217
271
|
return {"state": "missing"}
|
|
218
272
|
try:
|
|
219
|
-
r =
|
|
220
|
-
|
|
273
|
+
r = run_full(["docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}"],
|
|
274
|
+
timeout=5)
|
|
221
275
|
except (OSError, subprocess.SubprocessError):
|
|
222
276
|
return {"state": "down"}
|
|
223
277
|
if "permission denied" in r.stderr.lower():
|
|
@@ -232,21 +286,34 @@ def network():
|
|
|
232
286
|
ips = []
|
|
233
287
|
stats = psutil.net_if_stats()
|
|
234
288
|
for name, addrs in psutil.net_if_addrs().items():
|
|
235
|
-
if name == "lo" or name.startswith(("docker", "br-", "veth", "virbr"
|
|
289
|
+
if name == "lo" or name.startswith(("docker", "br-", "veth", "virbr", "Loopback",
|
|
290
|
+
"vEthernet", "VirtualBox", "VMware")):
|
|
236
291
|
continue
|
|
237
292
|
if name in stats and not stats[name].isup:
|
|
238
293
|
continue
|
|
239
294
|
for a in addrs:
|
|
240
|
-
if a.family == socket.AF_INET:
|
|
295
|
+
if a.family == socket.AF_INET and not a.address.startswith("169.254."):
|
|
241
296
|
ips.append((name, a.address))
|
|
242
|
-
wifi = None
|
|
243
|
-
if have("nmcli"):
|
|
244
|
-
for line in run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], timeout=3).splitlines():
|
|
245
|
-
if line.startswith("yes:"):
|
|
246
|
-
wifi = line[4:] or None
|
|
247
|
-
break
|
|
248
297
|
io = psutil.net_io_counters()
|
|
249
|
-
return {"ips": ips, "wifi":
|
|
298
|
+
return {"ips": ips, "wifi": wifi_name(), "sent": io.bytes_sent, "recv": io.bytes_recv}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def wifi_name():
|
|
302
|
+
if WINDOWS:
|
|
303
|
+
# " SSID : MyNetwork" (skip the BSSID line)
|
|
304
|
+
for line in run(["netsh", "wlan", "show", "interfaces"], timeout=4).splitlines():
|
|
305
|
+
key, _, value = line.partition(":")
|
|
306
|
+
if key.strip() == "SSID" and value.strip():
|
|
307
|
+
return value.strip()
|
|
308
|
+
return None
|
|
309
|
+
if have("nmcli"):
|
|
310
|
+
# Active connections only — `nmcli dev wifi` may trigger a slow rescan.
|
|
311
|
+
for line in run(["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"],
|
|
312
|
+
timeout=3).splitlines():
|
|
313
|
+
name, _, kind = line.rpartition(":")
|
|
314
|
+
if kind == "802-11-wireless" and name:
|
|
315
|
+
return name.replace("\\:", ":")
|
|
316
|
+
return None
|
|
250
317
|
|
|
251
318
|
|
|
252
319
|
def git_summary():
|
|
@@ -329,13 +396,17 @@ def health(d):
|
|
|
329
396
|
if stale:
|
|
330
397
|
hit(3 * len(stale), f"{len(stale)} dev server(s) running > 1 day → [bold]devmedic ports[/]")
|
|
331
398
|
if d["docker"]["state"] == "denied":
|
|
332
|
-
hit(2,
|
|
399
|
+
hit(2, DOCKER_FIX)
|
|
333
400
|
bat = d["sys"]["battery"]
|
|
334
401
|
if bat and not bat.power_plugged and bat.percent < 20:
|
|
335
402
|
tips.append(f"[yellow]Battery low ({bat.percent:.0f}%)[/] — plug in before long builds")
|
|
336
403
|
return max(score, 0), tips
|
|
337
404
|
|
|
338
405
|
|
|
406
|
+
DOCKER_FIX = ("Docker isn't reachable → start [bold]Docker Desktop[/]" if WINDOWS else
|
|
407
|
+
"Docker needs sudo → [bold]sudo usermod -aG docker $USER[/] then log out/in")
|
|
408
|
+
|
|
409
|
+
|
|
339
410
|
def grade(score):
|
|
340
411
|
for limit, g, color in ((90, "A", "green"), (75, "B", "cyan"), (60, "C", "yellow")):
|
|
341
412
|
if score >= limit:
|
|
@@ -350,7 +421,7 @@ def header(d, score):
|
|
|
350
421
|
g, color = grade(score)
|
|
351
422
|
info = Text.assemble(
|
|
352
423
|
(f"{s['user']}", "bold cyan"), ("@", "grey50"), (f"{s['host']}\n", "bold magenta"),
|
|
353
|
-
(f"{s['os']}", "bold"), (f" · kernel {s['kernel']}\n", "grey62"),
|
|
424
|
+
(f"{s['os']}", "bold"), (f" · {'build' if WINDOWS else 'kernel'} {s['kernel']}\n", "grey62"),
|
|
354
425
|
(f"up {human_age(s['boot']).replace(' ago', '')}", "green"),
|
|
355
426
|
(f" · {s['shell'] or 'shell ?'} · {time.strftime('%a %d %b %H:%M')}", "grey62"),
|
|
356
427
|
)
|
|
@@ -6,7 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
6
6
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
7
7
|
from rich.table import Table
|
|
8
8
|
|
|
9
|
-
from .util import (HOME, confirm, console, dir_size, display_path, human_age, human_size,
|
|
9
|
+
from .util import (HOME, confirm, console, dir_size, display_path, is_real_dir, human_age, human_size,
|
|
10
10
|
remove_tree, short_path, size_style)
|
|
11
11
|
|
|
12
12
|
# name -> (kind, check) ; check(parent_dir, junk_dir) says whether it's really junk
|
|
@@ -45,7 +45,9 @@ SKIP_DIRS = {".git", ".cache", ".local", ".npm", ".cargo", ".rustup", "snap",
|
|
|
45
45
|
".mozilla", ".config", ".vscode", ".vscode-server", ".var",
|
|
46
46
|
".m2", ".gradle", ".nvm", ".pyenv", ".docker", ".Trash"}
|
|
47
47
|
# Skipped only directly under $HOME / filesystem root.
|
|
48
|
-
SKIP_AT_TOP = {"go", "proc", "sys", "dev", "run", "tmp", "boot", "lost+found"
|
|
48
|
+
SKIP_AT_TOP = {"go", "proc", "sys", "dev", "run", "tmp", "boot", "lost+found",
|
|
49
|
+
# Windows: app data, cloud-synced folders (scanning can trigger downloads)
|
|
50
|
+
"AppData", "OneDrive", "iCloudDrive", "Dropbox", "scoop"}
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
def find(root, max_depth=8):
|
|
@@ -59,13 +61,13 @@ def find(root, max_depth=8):
|
|
|
59
61
|
except OSError:
|
|
60
62
|
continue
|
|
61
63
|
for e in entries:
|
|
62
|
-
if not e
|
|
64
|
+
if not is_real_dir(e):
|
|
63
65
|
continue
|
|
64
66
|
pattern = PATTERNS.get(e.name)
|
|
65
67
|
if pattern and pattern[1](current, e.path):
|
|
66
68
|
yield e.path, pattern[0]
|
|
67
69
|
continue
|
|
68
|
-
at_top = current
|
|
70
|
+
at_top = current == HOME or os.path.dirname(current) == current # ~ or a drive root
|
|
69
71
|
if depth < max_depth and e.name not in SKIP_DIRS and not (
|
|
70
72
|
at_top and (e.name.startswith(".") or e.name in SKIP_AT_TOP)):
|
|
71
73
|
stack.append((e.path, depth + 1))
|
|
@@ -76,13 +78,14 @@ def project_last_touched(junk_path):
|
|
|
76
78
|
parent = os.path.dirname(junk_path)
|
|
77
79
|
newest = 0
|
|
78
80
|
try:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
with os.scandir(parent) as it:
|
|
82
|
+
for e in it:
|
|
83
|
+
if e.path == junk_path or e.name in PATTERNS:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
newest = max(newest, e.stat(follow_symlinks=False).st_mtime)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
86
89
|
except OSError:
|
|
87
90
|
pass
|
|
88
91
|
return newest or os.stat(junk_path).st_mtime
|
|
@@ -9,7 +9,7 @@ from rich.table import Table
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
|
|
11
11
|
from . import caches, dashboard as db, ports, procs, repos
|
|
12
|
-
from .util import console, human_size, short_path
|
|
12
|
+
from .util import WINDOWS, console, human_size, short_path
|
|
13
13
|
|
|
14
14
|
# ───────────────────────── section details ─────────────────────────
|
|
15
15
|
|
|
@@ -50,8 +50,7 @@ def detail_tools(d):
|
|
|
50
50
|
t.add_row(*row[:3])
|
|
51
51
|
console.print(t)
|
|
52
52
|
elif dk["state"] == "denied":
|
|
53
|
-
console.print(
|
|
54
|
-
"then log out and back in.")
|
|
53
|
+
console.print(db.DOCKER_FIX)
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
def detail_projects(d):
|
|
@@ -188,7 +187,29 @@ KEYS = {b"\x1b[A": "up", b"\x1bOA": "up", b"k": "up",
|
|
|
188
187
|
b"r": "refresh", b"a": "all", b"w": "watch", b"\x1b[H": "home", b"\x1b[F": "end"}
|
|
189
188
|
|
|
190
189
|
|
|
190
|
+
# Windows (msvcrt) sends special keys as a prefix + one letter.
|
|
191
|
+
WINDOWS_KEYS = {"H": "up", "P": "down", "M": "enter", "K": "back", "G": "home", "O": "end"}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def decode_windows_key(first, second=""):
|
|
195
|
+
"""Map msvcrt.getwch() output to our key names (pure, so it's testable anywhere)."""
|
|
196
|
+
if first in ("\x00", "\xe0"):
|
|
197
|
+
return WINDOWS_KEYS.get(second, "other")
|
|
198
|
+
if first == "\x03":
|
|
199
|
+
raise KeyboardInterrupt
|
|
200
|
+
if first.isdigit():
|
|
201
|
+
return first
|
|
202
|
+
return KEYS.get(first.encode("utf-8", "ignore"), "other")
|
|
203
|
+
|
|
204
|
+
|
|
191
205
|
def read_key():
|
|
206
|
+
if WINDOWS:
|
|
207
|
+
import msvcrt
|
|
208
|
+
|
|
209
|
+
first = msvcrt.getwch()
|
|
210
|
+
second = msvcrt.getwch() if first in ("\x00", "\xe0") else ""
|
|
211
|
+
return decode_windows_key(first, second)
|
|
212
|
+
|
|
192
213
|
import termios # POSIX only — imported here so the package loads everywhere
|
|
193
214
|
import tty
|
|
194
215
|
|
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
"""Who is listening on which port, and killing them."""
|
|
2
|
-
import os
|
|
3
|
-
import signal
|
|
4
|
-
import time
|
|
5
|
-
|
|
6
2
|
import psutil
|
|
7
3
|
from rich.table import Table
|
|
8
4
|
|
|
9
|
-
from .util import confirm, console, short_path
|
|
5
|
+
from .util import WINDOWS, confirm, console, is_mine, short_path
|
|
10
6
|
|
|
11
7
|
|
|
12
8
|
# Friendly names for common root-owned services we can't inspect.
|
|
13
9
|
WELL_KNOWN = {22: "sshd", 25: "smtp", 53: "DNS (systemd-resolved)", 80: "http",
|
|
14
10
|
443: "https", 631: "CUPS printing", 3306: "MySQL", 5432: "PostgreSQL",
|
|
15
11
|
6379: "Redis", 27017: "MongoDB", 5353: "mDNS (avahi)", 2375: "Docker",
|
|
16
|
-
11434: "Ollama"
|
|
12
|
+
11434: "Ollama", 135: "Windows RPC", 139: "NetBIOS", 445: "SMB file sharing",
|
|
13
|
+
5040: "Windows CDP", 7680: "Delivery Optimization"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def admin_hint(pid=None, port=None):
|
|
17
|
+
"""How to do something as root/Administrator on this OS."""
|
|
18
|
+
if WINDOWS:
|
|
19
|
+
if pid:
|
|
20
|
+
return f"run PowerShell as Administrator: [bold]Stop-Process -Id {pid} -Force[/]"
|
|
21
|
+
return (f"run PowerShell as Administrator: "
|
|
22
|
+
f"[bold]Get-NetTCPConnection -LocalPort {port} | Select OwningProcess[/]")
|
|
23
|
+
if pid:
|
|
24
|
+
return f"try: [bold]sudo kill {pid}[/]"
|
|
25
|
+
return f"try: [bold]sudo ss -ltnp 'sport = :{port}'[/]"
|
|
17
26
|
|
|
18
27
|
|
|
19
28
|
def listening(include_system=False):
|
|
@@ -35,14 +44,16 @@ def listening(include_system=False):
|
|
|
35
44
|
"name": WELL_KNOWN.get(port, "?"), "cmd": "", "cwd": "", "user": "", "mine": False}
|
|
36
45
|
if c.pid:
|
|
37
46
|
try:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
# as_dict: fields we're not allowed to read come back as None
|
|
48
|
+
# instead of aborting (common on Windows for other users' processes).
|
|
49
|
+
d = psutil.Process(c.pid).as_dict(["name", "cmdline", "username", "cwd"])
|
|
50
|
+
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
|
51
|
+
d = {}
|
|
52
|
+
info["name"] = d.get("name") or info["name"]
|
|
53
|
+
info["cmd"] = " ".join(" ".join(d.get("cmdline") or []).split())
|
|
54
|
+
info["user"] = d.get("username") or ""
|
|
55
|
+
info["mine"] = is_mine(info["user"])
|
|
56
|
+
info["cwd"] = d.get("cwd") or ""
|
|
46
57
|
rows[key] = info
|
|
47
58
|
result = sorted(rows.values(), key=lambda r: r["port"])
|
|
48
59
|
if not include_system:
|
|
@@ -87,14 +98,14 @@ def kill(port, force=False, assume_yes=False):
|
|
|
87
98
|
for r in targets:
|
|
88
99
|
if not r["pid"]:
|
|
89
100
|
console.print(f"[red]Port {port} is held by a process you can't see "
|
|
90
|
-
f"(probably
|
|
101
|
+
f"(probably a system service).[/] To find it, {admin_hint(port=port)}")
|
|
91
102
|
status = 1
|
|
92
103
|
continue
|
|
93
104
|
console.print(f"Port [bold cyan]{port}[/] → PID {r['pid']} "
|
|
94
105
|
f"[bold]{r['name']}[/] [dim]{r['cmd'][:100]}[/]")
|
|
95
106
|
if not r["mine"]:
|
|
96
107
|
console.print(f"[yellow]Owned by {r['user'] or 'another user'}; "
|
|
97
|
-
f"you may need sudo.[/]")
|
|
108
|
+
f"you may need {'Administrator rights' if WINDOWS else 'sudo'}.[/]")
|
|
98
109
|
if not confirm("Kill it?", assume_yes):
|
|
99
110
|
continue
|
|
100
111
|
status |= terminate(r["pid"], force)
|
|
@@ -117,7 +128,7 @@ def terminate(pid, force=False):
|
|
|
117
128
|
except psutil.NoSuchProcess:
|
|
118
129
|
pass
|
|
119
130
|
except psutil.AccessDenied:
|
|
120
|
-
console.print(f"[red]Permission denied.[/]
|
|
131
|
+
console.print(f"[red]Permission denied.[/] To force it, {admin_hint(pid=pid)}")
|
|
121
132
|
return 1
|
|
122
133
|
except psutil.TimeoutExpired:
|
|
123
134
|
console.print(f"[red]PID {pid} is still alive.[/]")
|
|
@@ -6,7 +6,7 @@ from collections import defaultdict
|
|
|
6
6
|
import psutil
|
|
7
7
|
from rich.table import Table
|
|
8
8
|
|
|
9
|
-
from .util import HOME, console, human_age, human_size, short_path, size_style
|
|
9
|
+
from .util import HOME, console, human_age, human_size, is_mine, short_path, size_style
|
|
10
10
|
|
|
11
11
|
DEV_NAMES = {"node", "python", "python3", "java", "ruby", "go", "cargo", "rustc",
|
|
12
12
|
"deno", "bun", "php", "dotnet", "gradle", "mvn", "npm", "pnpm", "yarn",
|
|
@@ -21,7 +21,10 @@ def is_dev(name, cmdline):
|
|
|
21
21
|
base = name.split(".")[0].lower()
|
|
22
22
|
if base in DEV_NAMES or name.lower() in DEV_NAMES:
|
|
23
23
|
return True
|
|
24
|
-
|
|
24
|
+
if not cmdline:
|
|
25
|
+
return False
|
|
26
|
+
exe = os.path.basename(cmdline[0].replace("\\", "/")).lower()
|
|
27
|
+
return exe in DEV_NAMES or os.path.splitext(exe)[0] in DEV_NAMES
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def collect():
|
|
@@ -32,12 +35,12 @@ def collect():
|
|
|
32
35
|
ports[c.pid].add(c.laddr.port)
|
|
33
36
|
except (psutil.AccessDenied, OSError):
|
|
34
37
|
pass
|
|
35
|
-
|
|
38
|
+
self_pid = os.getpid()
|
|
36
39
|
rows = []
|
|
37
40
|
for p in psutil.process_iter(["pid", "name", "cmdline", "memory_info", "create_time",
|
|
38
|
-
"
|
|
41
|
+
"username", "cwd"]):
|
|
39
42
|
info = p.info
|
|
40
|
-
if
|
|
43
|
+
if info["pid"] == self_pid or not is_mine(info["username"]):
|
|
41
44
|
continue
|
|
42
45
|
if not is_dev(info["name"] or "", info["cmdline"] or []):
|
|
43
46
|
continue
|
|
@@ -7,7 +7,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
7
7
|
from rich.table import Table
|
|
8
8
|
|
|
9
9
|
from .junk import PATTERNS, SKIP_AT_TOP, SKIP_DIRS
|
|
10
|
-
from .util import HOME, console, display_path, human_age, short_path
|
|
10
|
+
from .util import HOME, console, display_path, human_age, is_real_dir, run_full, short_path
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def find(root, max_depth=6):
|
|
@@ -22,9 +22,9 @@ def find(root, max_depth=6):
|
|
|
22
22
|
entries = list(os.scandir(current))
|
|
23
23
|
except OSError:
|
|
24
24
|
continue
|
|
25
|
-
at_top = current
|
|
25
|
+
at_top = current == HOME or os.path.dirname(current) == current # ~ or a drive root
|
|
26
26
|
for e in entries:
|
|
27
|
-
if (depth < max_depth and e
|
|
27
|
+
if (depth < max_depth and is_real_dir(e)
|
|
28
28
|
and e.name not in SKIP_DIRS and e.name not in PATTERNS
|
|
29
29
|
and not (at_top and (e.name.startswith(".") or e.name in SKIP_AT_TOP))):
|
|
30
30
|
stack.append((e.path, depth + 1))
|
|
@@ -32,8 +32,7 @@ def find(root, max_depth=6):
|
|
|
32
32
|
|
|
33
33
|
def git(repo, *args):
|
|
34
34
|
try:
|
|
35
|
-
r =
|
|
36
|
-
timeout=15)
|
|
35
|
+
r = run_full(["git", "-C", repo, *args])
|
|
37
36
|
return r.stdout if r.returncode == 0 else ""
|
|
38
37
|
except (OSError, subprocess.SubprocessError):
|
|
39
38
|
return ""
|
|
@@ -1,13 +1,38 @@
|
|
|
1
|
-
"""Shared helpers: sizes, ages, confirmation prompts."""
|
|
1
|
+
"""Shared helpers: sizes, ages, confirmation prompts, cross-platform bits."""
|
|
2
|
+
import getpass
|
|
2
3
|
import os
|
|
3
4
|
import shutil
|
|
5
|
+
import stat
|
|
4
6
|
import subprocess
|
|
7
|
+
import sys
|
|
5
8
|
import time
|
|
6
9
|
|
|
7
10
|
from rich.console import Console
|
|
8
11
|
|
|
9
12
|
console = Console()
|
|
10
13
|
HOME = os.path.expanduser("~")
|
|
14
|
+
WINDOWS = sys.platform == "win32"
|
|
15
|
+
_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_real_dir(entry):
|
|
19
|
+
"""A directory we may descend into: not a symlink, and on Windows not a
|
|
20
|
+
junction/reparse point (pnpm and Windows itself use junctions)."""
|
|
21
|
+
try:
|
|
22
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
23
|
+
return False
|
|
24
|
+
if WINDOWS:
|
|
25
|
+
attrs = entry.stat(follow_symlinks=False).st_file_attributes
|
|
26
|
+
return not attrs & _REPARSE_POINT
|
|
27
|
+
except OSError:
|
|
28
|
+
return False
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def file_bytes(st):
|
|
33
|
+
"""Bytes a file really occupies on disk (st_blocks isn't available on Windows)."""
|
|
34
|
+
blocks = getattr(st, "st_blocks", None)
|
|
35
|
+
return blocks * 512 if blocks is not None else st.st_size
|
|
11
36
|
|
|
12
37
|
|
|
13
38
|
def dir_size(path):
|
|
@@ -24,16 +49,19 @@ def dir_size(path):
|
|
|
24
49
|
st = entry.stat(follow_symlinks=False)
|
|
25
50
|
except OSError:
|
|
26
51
|
continue
|
|
27
|
-
if entry
|
|
52
|
+
if is_real_dir(entry):
|
|
28
53
|
stack.append(entry.path)
|
|
29
54
|
continue
|
|
30
|
-
|
|
55
|
+
if entry.is_dir(follow_symlinks=False):
|
|
56
|
+
continue # junction: don't follow, don't count
|
|
57
|
+
# Hardlinked files (pnpm store, etc.) count once. On Windows
|
|
58
|
+
# scandir doesn't report link counts, so this is Linux/macOS only.
|
|
31
59
|
if st.st_nlink > 1:
|
|
32
60
|
key = (st.st_dev, st.st_ino)
|
|
33
61
|
if key in seen:
|
|
34
62
|
continue
|
|
35
63
|
seen.add(key)
|
|
36
|
-
total += st
|
|
64
|
+
total += file_bytes(st)
|
|
37
65
|
except OSError:
|
|
38
66
|
continue
|
|
39
67
|
return total
|
|
@@ -90,12 +118,22 @@ def confirm(question, assume_yes=False):
|
|
|
90
118
|
return answer.strip().lower() in ("y", "yes")
|
|
91
119
|
|
|
92
120
|
|
|
121
|
+
def resolve(cmd):
|
|
122
|
+
"""Full path for cmd[0] so Windows can launch npm.cmd, yarn.cmd, etc."""
|
|
123
|
+
exe = shutil.which(cmd[0])
|
|
124
|
+
return [exe, *cmd[1:]] if exe else list(cmd)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_full(cmd, timeout=15):
|
|
128
|
+
"""subprocess.run with text output that never fails on odd encodings."""
|
|
129
|
+
return subprocess.run(resolve(cmd), capture_output=True, text=True,
|
|
130
|
+
errors="replace", timeout=timeout)
|
|
131
|
+
|
|
132
|
+
|
|
93
133
|
def run(cmd, timeout=15):
|
|
94
134
|
"""Run a command, return stdout ('' on any failure)."""
|
|
95
135
|
try:
|
|
96
|
-
return
|
|
97
|
-
cmd, capture_output=True, text=True, timeout=timeout
|
|
98
|
-
).stdout
|
|
136
|
+
return run_full(cmd, timeout).stdout
|
|
99
137
|
except (OSError, subprocess.SubprocessError):
|
|
100
138
|
return ""
|
|
101
139
|
|
|
@@ -104,6 +142,32 @@ def have(binary):
|
|
|
104
142
|
return shutil.which(binary) is not None
|
|
105
143
|
|
|
106
144
|
|
|
145
|
+
def current_user():
|
|
146
|
+
try:
|
|
147
|
+
return getpass.getuser().lower()
|
|
148
|
+
except Exception: # no USER/USERNAME and no pwd entry
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_mine(username):
|
|
153
|
+
"""Does a process owner string (e.g. 'alice' or 'LAPTOP\\alice') mean us?"""
|
|
154
|
+
if not username:
|
|
155
|
+
return False
|
|
156
|
+
return username.lower().rsplit("\\", 1)[-1] == current_user()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _force_remove(func, path, _exc):
|
|
160
|
+
# Windows refuses to delete read-only files (e.g. git objects): clear the flag, retry.
|
|
161
|
+
try:
|
|
162
|
+
os.chmod(path, stat.S_IWRITE)
|
|
163
|
+
func(path)
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
107
168
|
def remove_tree(path):
|
|
108
|
-
|
|
169
|
+
if sys.version_info >= (3, 12):
|
|
170
|
+
shutil.rmtree(path, onexc=_force_remove)
|
|
171
|
+
else:
|
|
172
|
+
shutil.rmtree(path, onerror=_force_remove)
|
|
109
173
|
return not os.path.exists(path)
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devmedic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A health check for your developer machine: ports, junk folders, caches, git repos, dev processes — offline.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/yaswanthme007/devmedic
|
|
7
7
|
Project-URL: Issues, https://github.com/yaswanthme007/devmedic/issues
|
|
8
|
-
Keywords: developer-tools,cli,linux,ubuntu,cleanup,ports,disk-usage
|
|
8
|
+
Keywords: developer-tools,cli,linux,ubuntu,windows,cleanup,ports,disk-usage
|
|
9
9
|
Classifier: Environment :: Console
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
14
|
Classifier: Programming Language :: Python :: 3
|
|
14
15
|
Classifier: Topic :: Software Development
|
|
15
16
|
Classifier: Topic :: System :: Monitoring
|
|
@@ -47,16 +48,24 @@ devmedic answers all of them in seconds.
|
|
|
47
48
|
|
|
48
49
|
## Install
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
On **Windows**, use it inside [WSL](https://learn.microsoft.com/windows/wsl/install) for now; native support is planned.
|
|
51
|
+
Works on **Linux** (built on Ubuntu) and **Windows 10/11**, with **Python 3.9+**.
|
|
52
52
|
|
|
53
53
|
**With pipx (recommended)**
|
|
54
54
|
|
|
55
|
+
Linux:
|
|
56
|
+
|
|
55
57
|
```bash
|
|
56
58
|
sudo apt install pipx && pipx ensurepath # once
|
|
57
59
|
pipx install devmedic
|
|
58
60
|
```
|
|
59
61
|
|
|
62
|
+
Windows (PowerShell or Windows Terminal):
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
py -m pip install --user pipx; py -m pipx ensurepath # once, then open a new terminal
|
|
66
|
+
pipx install devmedic
|
|
67
|
+
```
|
|
68
|
+
|
|
60
69
|
**With pip**
|
|
61
70
|
|
|
62
71
|
```bash
|
|
@@ -115,7 +124,10 @@ devmedic is careful by design:
|
|
|
115
124
|
- **Nothing is deleted without confirmation** (unless you pass `-y`).
|
|
116
125
|
- **Conservative junk detection:** `target/` only next to `Cargo.toml`/`pom.xml`, venvs only if
|
|
117
126
|
they contain `pyvenv.cfg`. Generic `build/` and `dist/` folders are **never** touched.
|
|
118
|
-
- **
|
|
127
|
+
- **Links are never followed:** symlinks and Windows junctions (used by pnpm) are skipped when
|
|
128
|
+
scanning and removed as links, so nothing outside the folder is ever deleted.
|
|
129
|
+
- **Never runs `sudo` or Administrator commands.** System fixes (journal, apt, snap, Recycle Bin,
|
|
130
|
+
docker) are printed for you to copy.
|
|
119
131
|
- **Only kills what you confirm**, and can't touch other users' processes.
|
|
120
132
|
- **Offline and private:** no network requests, no data collection.
|
|
121
133
|
|
|
@@ -10,12 +10,13 @@ readme = "README.md"
|
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
requires-python = ">=3.9"
|
|
12
12
|
dependencies = ["psutil>=5.8", "rich>=12.0"]
|
|
13
|
-
keywords = ["developer-tools", "cli", "linux", "ubuntu", "cleanup", "ports", "disk-usage"]
|
|
13
|
+
keywords = ["developer-tools", "cli", "linux", "ubuntu", "windows", "cleanup", "ports", "disk-usage"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Environment :: Console",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
20
|
"Programming Language :: Python :: 3",
|
|
20
21
|
"Topic :: Software Development",
|
|
21
22
|
"Topic :: System :: Monitoring",
|
|
@@ -10,7 +10,14 @@ import time
|
|
|
10
10
|
import unittest
|
|
11
11
|
from contextlib import redirect_stdout
|
|
12
12
|
|
|
13
|
-
from devmedic import caches, cli, junk, ports, repos, util
|
|
13
|
+
from devmedic import caches, cli, junk, menu, ports, repos, util
|
|
14
|
+
|
|
15
|
+
WINDOWS = sys.platform == "win32"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def rel(path, root):
|
|
19
|
+
"""Relative path with forward slashes, so expectations work on every OS."""
|
|
20
|
+
return os.path.relpath(path, root).replace(os.sep, "/")
|
|
14
21
|
|
|
15
22
|
|
|
16
23
|
def write(path, size=1000):
|
|
@@ -33,6 +40,7 @@ class UtilTests(unittest.TestCase):
|
|
|
33
40
|
with self.assertRaises(ValueError):
|
|
34
41
|
junk.parse_selection("abc", 3)
|
|
35
42
|
|
|
43
|
+
@unittest.skipIf(WINDOWS, "scandir doesn't report hardlink counts on Windows")
|
|
36
44
|
def test_dir_size_counts_hardlinks_once(self):
|
|
37
45
|
with tempfile.TemporaryDirectory() as d:
|
|
38
46
|
write(os.path.join(d, "a"), 100_000)
|
|
@@ -59,14 +67,15 @@ class JunkTests(unittest.TestCase):
|
|
|
59
67
|
shutil.rmtree(self.root, ignore_errors=True)
|
|
60
68
|
|
|
61
69
|
def found(self):
|
|
62
|
-
return sorted(
|
|
70
|
+
return sorted(rel(p, self.root) for p, _ in junk.find(self.root))
|
|
63
71
|
|
|
64
72
|
def test_detects_only_real_junk(self):
|
|
65
73
|
self.assertEqual(self.found(), ["py2/.venv", "rust/target", "web/node_modules"])
|
|
66
74
|
|
|
67
75
|
def test_clean_deletes_only_selected(self):
|
|
68
76
|
items = junk.scan(self.root)
|
|
69
|
-
target = [i for i in items if i["path"].
|
|
77
|
+
target = [i for i in items if rel(i["path"], self.root) == "rust/target"]
|
|
78
|
+
self.assertEqual(len(target), 1)
|
|
70
79
|
with redirect_stdout(io.StringIO()):
|
|
71
80
|
junk.clean(target, assume_yes=True)
|
|
72
81
|
self.assertFalse(os.path.exists(os.path.join(self.root, "rust", "target")))
|
|
@@ -78,7 +87,7 @@ class JunkTests(unittest.TestCase):
|
|
|
78
87
|
old = os.path.join(self.root, "web", "package.json")
|
|
79
88
|
past = time.time() - 90 * 86400
|
|
80
89
|
os.utime(old, (past, past))
|
|
81
|
-
paths = [
|
|
90
|
+
paths = [rel(i["path"], self.root) for i in junk.scan(self.root, 30)]
|
|
82
91
|
self.assertEqual(paths, ["web/node_modules"])
|
|
83
92
|
|
|
84
93
|
|
|
@@ -173,24 +182,69 @@ class CacheTests(unittest.TestCase):
|
|
|
173
182
|
shutil.rmtree(u["path"])
|
|
174
183
|
|
|
175
184
|
|
|
176
|
-
class
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
self.assertEqual(
|
|
192
|
-
self.
|
|
193
|
-
|
|
185
|
+
class CrossPlatformTests(unittest.TestCase):
|
|
186
|
+
def test_is_mine(self):
|
|
187
|
+
me = util.current_user()
|
|
188
|
+
self.assertTrue(util.is_mine(me))
|
|
189
|
+
self.assertTrue(util.is_mine("SOME-PC\\" + me.upper())) # Windows DOMAIN\user
|
|
190
|
+
self.assertFalse(util.is_mine("definitely-not-" + me))
|
|
191
|
+
self.assertFalse(util.is_mine(None))
|
|
192
|
+
|
|
193
|
+
def test_windows_key_decoding(self):
|
|
194
|
+
d = menu.decode_windows_key
|
|
195
|
+
self.assertEqual(d("\xe0", "H"), "up")
|
|
196
|
+
self.assertEqual(d("\x00", "P"), "down")
|
|
197
|
+
self.assertEqual(d("\r"), "enter")
|
|
198
|
+
self.assertEqual(d("q"), "quit")
|
|
199
|
+
self.assertEqual(d("7"), "7")
|
|
200
|
+
self.assertEqual(d("\xe0", "Z"), "other")
|
|
201
|
+
with self.assertRaises(KeyboardInterrupt):
|
|
202
|
+
d("\x03")
|
|
203
|
+
|
|
204
|
+
def test_utf8_output_survives_legacy_encoding(self):
|
|
205
|
+
buf = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
|
|
206
|
+
old = sys.stdout
|
|
207
|
+
try:
|
|
208
|
+
sys.stdout = buf
|
|
209
|
+
cli.utf8_output()
|
|
210
|
+
print("🩺 ████ ╭─╮") # would raise UnicodeEncodeError in cp1252
|
|
211
|
+
buf.flush()
|
|
212
|
+
finally:
|
|
213
|
+
sys.stdout = old
|
|
214
|
+
self.assertIn("🩺".encode("utf-8"), buf.buffer.getvalue())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@unittest.skipUnless(WINDOWS, "Windows junctions")
|
|
218
|
+
class JunctionTests(unittest.TestCase):
|
|
219
|
+
"""pnpm and Windows use junctions; we must never follow or delete through them."""
|
|
220
|
+
|
|
221
|
+
def setUp(self):
|
|
222
|
+
self.root = tempfile.mkdtemp()
|
|
223
|
+
self.target = os.path.join(self.root, "real")
|
|
224
|
+
write(os.path.join(self.target, "node_modules", "pkg", "big"), 200_000)
|
|
225
|
+
write(os.path.join(self.target, "package.json"))
|
|
226
|
+
self.link = os.path.join(self.root, "link")
|
|
227
|
+
subprocess.run(["cmd", "/c", "mklink", "/J", self.link, self.target], check=True,
|
|
228
|
+
capture_output=True)
|
|
229
|
+
|
|
230
|
+
def tearDown(self):
|
|
231
|
+
subprocess.run(["cmd", "/c", "rmdir", self.link], capture_output=True)
|
|
232
|
+
shutil.rmtree(self.root, ignore_errors=True)
|
|
233
|
+
|
|
234
|
+
def test_scan_does_not_follow_junction(self):
|
|
235
|
+
found = sorted(rel(p, self.root) for p, _ in junk.find(self.root))
|
|
236
|
+
self.assertEqual(found, ["real/node_modules"])
|
|
237
|
+
|
|
238
|
+
def test_size_ignores_junction(self):
|
|
239
|
+
with_link = util.dir_size(self.root)
|
|
240
|
+
self.assertLess(with_link, 2 * util.dir_size(self.target))
|
|
241
|
+
|
|
242
|
+
def test_removing_junction_keeps_target(self):
|
|
243
|
+
with os.scandir(self.root) as it:
|
|
244
|
+
entry = next(e for e in it if e.name == "link")
|
|
245
|
+
self.assertTrue(caches._remove_entry(entry))
|
|
246
|
+
self.assertFalse(os.path.exists(self.link))
|
|
247
|
+
self.assertTrue(os.path.exists(os.path.join(self.target, "node_modules", "pkg", "big")))
|
|
194
248
|
|
|
195
249
|
|
|
196
250
|
class SmokeTests(unittest.TestCase):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|