disk-space-saviour 0.13.1 → 0.15.1
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/CHANGELOG.md +28 -0
- package/README.md +88 -27
- package/bin/dss.js +12 -1
- package/package.json +5 -5
- package/src/audit.js +6 -0
- package/src/clean.js +166 -32
- package/src/cli.js +12 -2
- package/src/docker/cli.js +33 -5
- package/src/docker/containers.js +63 -7
- package/src/docker/scan.js +170 -46
- package/src/docker/snapshots.js +65 -0
- package/src/docker/writable.js +288 -0
- package/src/emergency.js +12 -3
- package/src/env/local.js +162 -78
- package/src/env/policy.js +155 -0
- package/src/env/proc.js +53 -0
- package/src/env/shell.js +190 -12
- package/src/exec.js +4 -1
- package/src/git-preserved.js +120 -0
- package/src/git.js +34 -6
- package/src/index.d.ts +14 -2
- package/src/liveness.js +160 -13
- package/src/options.js +5 -0
- package/src/paths.js +34 -2
- package/src/report.js +67 -7
- package/src/rules/apps.js +178 -0
- package/src/rules/ecosystems.js +4 -3
- package/src/rules/other.js +29 -5
- package/src/rules/versions.js +23 -0
- package/src/scan.js +86 -12
- package/src/scanners/agents.js +1 -1
- package/src/scanners/common.js +2 -1
- package/src/scanners/global.js +59 -7
- package/src/scanners/projects.js +4 -8
- package/src/scanners/system.js +4 -4
- package/src/scanners/versions.js +16 -4
package/src/liveness.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Liveness checks: is anything using a path right now?
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Independent signals, any of which blocks deletion:
|
|
5
5
|
* - recent writes: newest file mtime inside the stale-age window;
|
|
6
6
|
* - a relevant tool is running (by process name), optionally only when its
|
|
7
7
|
* working directory is inside the project;
|
|
8
8
|
* - a process holds a file, working directory, or executable inside the path
|
|
9
9
|
* (`/proc/<pid>/{fd,cwd,exe}` on Linux, `lsof` on macOS).
|
|
10
|
+
* - a command argument names a path inside the item (including scripts
|
|
11
|
+
* whose interpreter has already closed its input descriptor).
|
|
10
12
|
*
|
|
11
13
|
* The cleaner refreshes the probe right before acting on each item.
|
|
12
14
|
*/
|
|
13
15
|
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
14
18
|
import { isWithin } from './paths.js';
|
|
15
19
|
import { formatDuration } from './units.js';
|
|
16
20
|
|
|
@@ -25,6 +29,76 @@ function nameMatches(name, candidate) {
|
|
|
25
29
|
);
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/** Arguments preserved by /proc; command titles are a fallback. */
|
|
33
|
+
export function processArgs(proc) {
|
|
34
|
+
return (
|
|
35
|
+
proc.argv ??
|
|
36
|
+
(proc.command ?? '')
|
|
37
|
+
.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)
|
|
38
|
+
?.map((arg) => arg.replace(/^["']|["']$/g, '')) ??
|
|
39
|
+
[]
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scriptIndex(args) {
|
|
44
|
+
const first = path.posix.basename(args[0] ?? '');
|
|
45
|
+
if (!/^(node|nodejs|bun|deno|python[\d.]*|bash|sh)$/.test(first)) {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
// Only the interpreter's script operand; option values and arbitrary
|
|
49
|
+
// arguments are not executable names.
|
|
50
|
+
let index = 1;
|
|
51
|
+
while (args[index]?.startsWith('-')) {
|
|
52
|
+
if (['-e', '--eval', '-p', '--print', '-c'].includes(args[index])) {
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
index += ['-r', '--require', '--import', '--loader'].includes(args[index])
|
|
56
|
+
? 2
|
|
57
|
+
: 1;
|
|
58
|
+
}
|
|
59
|
+
return index;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commandNames(proc) {
|
|
63
|
+
const args = processArgs(proc);
|
|
64
|
+
const first = path.posix.basename(args[0] ?? '');
|
|
65
|
+
const names = [first];
|
|
66
|
+
if (/^npm(?: |$)/.test(proc.name ?? '') || /^npm(?: |$)/.test(first)) {
|
|
67
|
+
names.push('npm');
|
|
68
|
+
}
|
|
69
|
+
const index = scriptIndex(args);
|
|
70
|
+
if (index > 0) {
|
|
71
|
+
const script = path.posix.basename(args[index] ?? '');
|
|
72
|
+
names.push(
|
|
73
|
+
script,
|
|
74
|
+
...(script === 'npm-cli.js' ? ['npm'] : []),
|
|
75
|
+
...(script === 'npx-cli.js' ? ['npx'] : [])
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
// An explicit npm launcher package is also a tool (e.g. Playwright MCP).
|
|
79
|
+
if (isNpmExec(proc)) {
|
|
80
|
+
const packageArg = args.find((arg) => /^@[^/]+\/[^/]+$/.test(arg));
|
|
81
|
+
if (packageArg) {
|
|
82
|
+
names.push(packageArg.slice(1).split('/')[0]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return names;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isNpmExec(proc) {
|
|
89
|
+
const args = processArgs(proc);
|
|
90
|
+
const index = scriptIndex(args);
|
|
91
|
+
const executable = path.posix.basename(args[Math.max(index, 0)] ?? '');
|
|
92
|
+
return (
|
|
93
|
+
/^npm (exec|x)(?: |$)|^npx(?: |$)/.test(proc.name ?? '') ||
|
|
94
|
+
/^npm (exec|x)(?: |$)|^npx(?: |$)/.test(args[0] ?? '') ||
|
|
95
|
+
(index >= 0 &&
|
|
96
|
+
(['npx', 'npx-cli.js'].includes(executable) ||
|
|
97
|
+
(['npm', 'npm-cli.js'].includes(executable) &&
|
|
98
|
+
['exec', 'x'].includes(args[index + 1]))))
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
28
102
|
/**
|
|
29
103
|
* Name of `proc` that matches one of `names`, or null. Besides the kernel
|
|
30
104
|
* name (`comm`, which programs may rename: Node.js calls its main thread
|
|
@@ -32,18 +106,12 @@ function nameMatches(name, candidate) {
|
|
|
32
106
|
* base names.
|
|
33
107
|
*/
|
|
34
108
|
export function matchingName(proc, names) {
|
|
35
|
-
const own = [proc.name ?? '', ...(proc.aliases ?? [])];
|
|
109
|
+
const own = [proc.name ?? '', ...(proc.aliases ?? []), ...commandNames(proc)];
|
|
36
110
|
for (const name of own) {
|
|
37
111
|
if (name && names.some((candidate) => nameMatches(name, candidate))) {
|
|
38
112
|
return name;
|
|
39
113
|
}
|
|
40
114
|
}
|
|
41
|
-
const commandWords = (proc.command ?? '').toLowerCase().split(/[^a-z0-9_-]+/);
|
|
42
|
-
for (const candidate of names) {
|
|
43
|
-
if (commandWords.includes(candidate.toLowerCase())) {
|
|
44
|
-
return candidate;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
115
|
return null;
|
|
48
116
|
}
|
|
49
117
|
|
|
@@ -62,16 +130,17 @@ export class LivenessProbe {
|
|
|
62
130
|
this.openPaths = null;
|
|
63
131
|
this.probeError = false;
|
|
64
132
|
this.realPaths = new Map();
|
|
133
|
+
this.pathOwners = new Map();
|
|
65
134
|
}
|
|
66
135
|
|
|
67
136
|
async refresh(force = false) {
|
|
68
137
|
if (!force && this.now() - this.refreshedAt < this.refreshMs) {
|
|
69
138
|
return;
|
|
70
139
|
}
|
|
140
|
+
this.openPaths = await this.env.openPaths().catch(() => null);
|
|
71
141
|
const processes = await this.env.processes().catch(() => null);
|
|
72
142
|
this.listed = processes !== null;
|
|
73
143
|
this.processes = processes ?? [];
|
|
74
|
-
this.openPaths = await this.env.openPaths().catch(() => null);
|
|
75
144
|
this.probeError =
|
|
76
145
|
(processes === null || this.openPaths === null) &&
|
|
77
146
|
(this.env.platform === 'linux' || this.env.platform === 'darwin');
|
|
@@ -90,6 +159,11 @@ export class LivenessProbe {
|
|
|
90
159
|
* @param {string[]} paths
|
|
91
160
|
*/
|
|
92
161
|
async resolve(paths) {
|
|
162
|
+
if (this.env.unreadableProcesses?.length && this.env.stat) {
|
|
163
|
+
for (const target of paths) {
|
|
164
|
+
this.pathOwners.set(target, (await this.env.stat(target))?.uid);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
93
167
|
if (typeof this.env.realPath !== 'function') {
|
|
94
168
|
return;
|
|
95
169
|
}
|
|
@@ -111,12 +185,18 @@ export class LivenessProbe {
|
|
|
111
185
|
return null;
|
|
112
186
|
}
|
|
113
187
|
|
|
114
|
-
runningTool(names, cwd) {
|
|
188
|
+
runningTool(names, cwd, item = {}) {
|
|
115
189
|
if (!names || names.length === 0) {
|
|
116
190
|
return null;
|
|
117
191
|
}
|
|
118
192
|
const pathApi = this.env.path;
|
|
119
193
|
for (const proc of this.processes) {
|
|
194
|
+
if (
|
|
195
|
+
['npm-cache', 'node-gyp-cache', 'npx-cache'].includes(item.rule) &&
|
|
196
|
+
isNpmExec(proc)
|
|
197
|
+
) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
120
200
|
const name = matchingName(proc, names);
|
|
121
201
|
if (!name) {
|
|
122
202
|
continue;
|
|
@@ -169,6 +249,58 @@ export class LivenessProbe {
|
|
|
169
249
|
return open;
|
|
170
250
|
}
|
|
171
251
|
|
|
252
|
+
commandUsage(paths) {
|
|
253
|
+
for (const proc of this.processes) {
|
|
254
|
+
for (const arg of processArgs(proc)) {
|
|
255
|
+
const candidate = arg.startsWith('/')
|
|
256
|
+
? arg
|
|
257
|
+
: proc.cwd && !arg.startsWith('-')
|
|
258
|
+
? this.env.path.resolve(proc.cwd, arg)
|
|
259
|
+
: null;
|
|
260
|
+
if (
|
|
261
|
+
candidate &&
|
|
262
|
+
paths.some((target) => isWithin(candidate, target, this.env.path))
|
|
263
|
+
) {
|
|
264
|
+
return `in use: ${candidate} (pid ${proc.pid})`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
uncertainUsage(paths) {
|
|
272
|
+
for (const proc of this.env.unreadableProcesses ?? []) {
|
|
273
|
+
const args = processArgs(proc);
|
|
274
|
+
const roots = args.flatMap((arg) => {
|
|
275
|
+
const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
|
|
276
|
+
return value.startsWith('/') ? [value] : [];
|
|
277
|
+
});
|
|
278
|
+
if (proc.name === 'dockerd') {
|
|
279
|
+
roots.push('/var/lib/docker', '/run');
|
|
280
|
+
}
|
|
281
|
+
if (proc.name === 'containerd') {
|
|
282
|
+
roots.push('/var/lib/containerd', '/run/containerd');
|
|
283
|
+
}
|
|
284
|
+
for (const target of paths) {
|
|
285
|
+
const intersects = roots.some(
|
|
286
|
+
(root) =>
|
|
287
|
+
isWithin(root, target, this.env.path) ||
|
|
288
|
+
isWithin(target, root, this.env.path)
|
|
289
|
+
);
|
|
290
|
+
const owner = this.pathOwners.get(target);
|
|
291
|
+
if (
|
|
292
|
+
intersects ||
|
|
293
|
+
proc.uid === null ||
|
|
294
|
+
owner === undefined ||
|
|
295
|
+
owner === proc.uid
|
|
296
|
+
) {
|
|
297
|
+
return `unreadable process ${proc.name || 'unknown'} (pid ${proc.pid}, uid ${proc.uid ?? 'unknown'}) may use ${target}`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
172
304
|
/**
|
|
173
305
|
* First reason the item is busy, or null when it is idle. Items with
|
|
174
306
|
* `checks.perPath` leave open files to `openPathsOf`: the caller skips
|
|
@@ -179,12 +311,27 @@ export class LivenessProbe {
|
|
|
179
311
|
const checks = item.checks ?? {};
|
|
180
312
|
const paths = checks.perPath ? [] : (item.paths ?? []);
|
|
181
313
|
return (
|
|
314
|
+
this.installationBlocker(item.paths ?? []) ??
|
|
182
315
|
(this.probeError
|
|
183
|
-
?
|
|
316
|
+
? `cannot verify process and open-file activity${this.env.probeDiagnostic ? `: ${this.env.probeDiagnostic}` : ''}`
|
|
184
317
|
: null) ??
|
|
185
318
|
(checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
|
|
186
|
-
this.runningTool(checks.busy, checks.cwd) ??
|
|
187
|
-
this.openInside(paths)
|
|
319
|
+
this.runningTool(checks.busy, checks.cwd, item) ??
|
|
320
|
+
this.openInside(paths) ??
|
|
321
|
+
this.commandUsage(paths) ??
|
|
322
|
+
this.uncertainUsage(item.paths ?? [])
|
|
188
323
|
);
|
|
189
324
|
}
|
|
325
|
+
|
|
326
|
+
installationBlocker(paths) {
|
|
327
|
+
return paths.some((target) =>
|
|
328
|
+
(this.env.protectedPaths ?? []).some(
|
|
329
|
+
(root) =>
|
|
330
|
+
isWithin(root, target, this.env.path) ||
|
|
331
|
+
isWithin(target, root, this.env.path)
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
? 'dss current installation is protected'
|
|
335
|
+
: null;
|
|
336
|
+
}
|
|
190
337
|
}
|
package/src/options.js
CHANGED
|
@@ -21,6 +21,7 @@ export const DEFAULTS = {
|
|
|
21
21
|
exclude: [],
|
|
22
22
|
includeVolumes: false,
|
|
23
23
|
removeStoppedContainers: false,
|
|
24
|
+
investigationMaxAge: null,
|
|
24
25
|
removeContainers: [],
|
|
25
26
|
removeImages: [],
|
|
26
27
|
allowDirtyRepos: false,
|
|
@@ -77,6 +78,10 @@ export function resolveOptions(input = {}) {
|
|
|
77
78
|
dockerDepth: Number(merged.dockerDepth),
|
|
78
79
|
staleAgeMs: toMs(staleAge),
|
|
79
80
|
inactiveMs: toMs(merged.inactive),
|
|
81
|
+
investigationMaxAgeMs:
|
|
82
|
+
merged.investigationMaxAge === null
|
|
83
|
+
? null
|
|
84
|
+
: toMs(merged.investigationMaxAge),
|
|
80
85
|
minSizeBytes: toBytes(merged.minSize),
|
|
81
86
|
journalKeepBytes: toBytes(merged.journalKeep),
|
|
82
87
|
containerFilter: toList(merged.containers),
|
package/src/paths.js
CHANGED
|
@@ -13,8 +13,17 @@ export function globToRegExp(glob) {
|
|
|
13
13
|
let cached = globCache.get(glob);
|
|
14
14
|
if (!cached) {
|
|
15
15
|
let source = '';
|
|
16
|
-
for (
|
|
17
|
-
|
|
16
|
+
for (let index = 0; index < glob.length; index++) {
|
|
17
|
+
const char = glob[index];
|
|
18
|
+
if (char === '*' && glob[index + 1] === '*') {
|
|
19
|
+
index++;
|
|
20
|
+
if (glob[index + 1] === '/') {
|
|
21
|
+
source += '(?:.*/)?';
|
|
22
|
+
index++;
|
|
23
|
+
} else {
|
|
24
|
+
source += '.*';
|
|
25
|
+
}
|
|
26
|
+
} else if (char === '*') {
|
|
18
27
|
source += '[^/\\\\]*';
|
|
19
28
|
} else if (char === '?') {
|
|
20
29
|
source += '[^/\\\\]';
|
|
@@ -38,6 +47,29 @@ export function matchesGlob(name, glob) {
|
|
|
38
47
|
return globToRegExp(glob).test(name);
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
/** Match exclusions against a path and each ancestor, including dotfiles. */
|
|
51
|
+
export function matchesExcluded(target, patterns, pathApi) {
|
|
52
|
+
const normalize = (value) => {
|
|
53
|
+
const normalized = value.replaceAll('\\', '/');
|
|
54
|
+
return pathApi.sep === '\\' ? normalized.toLowerCase() : normalized;
|
|
55
|
+
};
|
|
56
|
+
for (const pattern of patterns ?? []) {
|
|
57
|
+
const full = /[/\\]/.test(pattern);
|
|
58
|
+
for (let current = pathApi.resolve(target); ; ) {
|
|
59
|
+
const candidate = full ? current : pathApi.basename(current);
|
|
60
|
+
if (matchesGlob(normalize(candidate), normalize(pattern))) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
const parent = pathApi.dirname(current);
|
|
64
|
+
if (parent === current) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
current = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
/**
|
|
42
74
|
* Whether `child` equals `parent` or lies below it.
|
|
43
75
|
* @param {string} child
|
package/src/report.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { TIERS, consentFlag } from './items.js';
|
|
7
|
-
import { formatBytes } from './units.js';
|
|
7
|
+
import { formatBytes, formatDuration } from './units.js';
|
|
8
8
|
|
|
9
9
|
const DEFAULT_LIMIT = 25;
|
|
10
10
|
|
|
@@ -51,7 +51,12 @@ function itemLine(item, showEnv) {
|
|
|
51
51
|
const confirm = item.requiresConfirmation
|
|
52
52
|
? ` (needs ${consentFlag(item)})`
|
|
53
53
|
: '';
|
|
54
|
-
|
|
54
|
+
const storage = item.sizeUnknown
|
|
55
|
+
? ' (may be in an image layer; freeable bytes unknown, excluded from totals)'
|
|
56
|
+
: item.imageBytes
|
|
57
|
+
? ` (${formatBytes(item.imageBytes)} in image${item.imageRef ? ` ${item.imageRef}` : ''}; 0 B of that data freeable now; remove the stopped container and unused image after the task completes)`
|
|
58
|
+
: '';
|
|
59
|
+
return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
function limited(lines, limit) {
|
|
@@ -108,7 +113,12 @@ function repoSummary(repo) {
|
|
|
108
113
|
}
|
|
109
114
|
const parts = [];
|
|
110
115
|
if (repo.dirty) {
|
|
111
|
-
parts.push(
|
|
116
|
+
parts.push(
|
|
117
|
+
`${repo.dirty} uncommitted${repo.untracked ? ` (${repo.untracked} untracked)` : ''}`
|
|
118
|
+
);
|
|
119
|
+
if (repo.preservedOn) {
|
|
120
|
+
parts.push(`uncommitted work preserved on ${repo.preservedOn}`);
|
|
121
|
+
}
|
|
112
122
|
}
|
|
113
123
|
if (repo.unpushed) {
|
|
114
124
|
parts.push(`${repo.unpushed} unpushed`);
|
|
@@ -155,6 +165,14 @@ function containerLines(container, stoppedItems) {
|
|
|
155
165
|
lines.push(` ${ended}`);
|
|
156
166
|
}
|
|
157
167
|
const item = stoppedItems.get(container.id);
|
|
168
|
+
if (container.sizeUnknown) {
|
|
169
|
+
lines.push(' writable layer size unknown');
|
|
170
|
+
}
|
|
171
|
+
if (container.pinnedImage) {
|
|
172
|
+
lines.push(
|
|
173
|
+
` ${formatBytes(container.pinnedImage.bytes)} unique image bytes pinned by this container (${container.pinnedImage.ref}); after removing it, approve the unused image separately with --remove-image ${container.pinnedImage.ref}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
158
176
|
if (item?.container?.investigation) {
|
|
159
177
|
lines.push(` ${item.container.investigation}`);
|
|
160
178
|
}
|
|
@@ -192,6 +210,25 @@ function imageLine(item) {
|
|
|
192
210
|
)}) unused, ${formatBytes(image.bytes)}, ${created}${lastUseText(image.lastUsedBy)}${consent}`;
|
|
193
211
|
}
|
|
194
212
|
|
|
213
|
+
function investigationSummary(docker, stoppedItems, createdAt) {
|
|
214
|
+
const lines = [];
|
|
215
|
+
const kept = docker.containers.filter(
|
|
216
|
+
(container) => stoppedItems.get(container.id)?.container?.investigation
|
|
217
|
+
);
|
|
218
|
+
if (kept.length) {
|
|
219
|
+
const dates = kept
|
|
220
|
+
.map((container) => Date.parse(container.owner?.finishedAt))
|
|
221
|
+
.filter(Number.isFinite);
|
|
222
|
+
const age = dates.length
|
|
223
|
+
? `, oldest ${formatDuration(Math.max(0, Date.parse(createdAt) - Math.min(...dates)))}`
|
|
224
|
+
: '';
|
|
225
|
+
lines.push(
|
|
226
|
+
` ${formatBytes(kept.reduce((sum, container) => sum + container.bytes, 0))} in ${kept.length} containers kept for investigation${age}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
|
|
195
232
|
function dockerSection(report) {
|
|
196
233
|
const docker = report.docker;
|
|
197
234
|
if (!docker || docker.daemons.length === 0) {
|
|
@@ -202,7 +239,11 @@ function dockerSection(report) {
|
|
|
202
239
|
.filter((item) => item.container)
|
|
203
240
|
.map((item) => [item.container.id, item])
|
|
204
241
|
);
|
|
205
|
-
const lines = [
|
|
242
|
+
const lines = [
|
|
243
|
+
'',
|
|
244
|
+
'DOCKER',
|
|
245
|
+
...investigationSummary(docker, stoppedItems, report.createdAt),
|
|
246
|
+
];
|
|
206
247
|
for (const daemon of docker.daemons) {
|
|
207
248
|
lines.push(
|
|
208
249
|
` daemon ${daemon.name} (depth ${daemon.depth}, ${daemon.serverVersion}, ${daemon.driver}) via ${daemon.env}`
|
|
@@ -214,9 +255,20 @@ function dockerSection(report) {
|
|
|
214
255
|
for (const item of report.items.filter((entry) => entry.image)) {
|
|
215
256
|
lines.push(imageLine(item));
|
|
216
257
|
}
|
|
258
|
+
const grouped = new Map();
|
|
217
259
|
for (const hint of docker.hints ?? []) {
|
|
260
|
+
const key = `${(hint.images ?? [hint.image]).join(', ')}\0${hint.hint}`;
|
|
261
|
+
const group = grouped.get(key);
|
|
262
|
+
if (group) {
|
|
263
|
+
group.bytes += hint.bytes;
|
|
264
|
+
group.layers += 1;
|
|
265
|
+
} else {
|
|
266
|
+
grouped.set(key, { ...hint, layers: 1 });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const hint of grouped.values()) {
|
|
218
270
|
lines.push(
|
|
219
|
-
` hint ${(hint.images ?? [hint.image]).join(', ')}: ${formatBytes(hint.bytes)} layer, ${hint.hint}`
|
|
271
|
+
` hint ${(hint.images ?? [hint.image]).join(', ')}: ${formatBytes(hint.bytes)} in ${hint.layers} layer${hint.layers === 1 ? '' : 's'}, ${hint.hint}`
|
|
220
272
|
);
|
|
221
273
|
}
|
|
222
274
|
for (const volume of docker.volumes ?? []) {
|
|
@@ -246,7 +298,7 @@ function errorsSection(errors) {
|
|
|
246
298
|
}
|
|
247
299
|
return [
|
|
248
300
|
'',
|
|
249
|
-
'ERRORS',
|
|
301
|
+
'ERRORS (scan incomplete)',
|
|
250
302
|
...errors.map(
|
|
251
303
|
(error) => ` ${error.env} ${error.scanner}: ${error.message}`
|
|
252
304
|
),
|
|
@@ -269,6 +321,11 @@ export function formatReport(report, options = {}) {
|
|
|
269
321
|
for (const tier of TIERS) {
|
|
270
322
|
lines.push(...tierSection(report, tier, { limit }));
|
|
271
323
|
}
|
|
324
|
+
for (const env of report.environments) {
|
|
325
|
+
if (env.hint) {
|
|
326
|
+
lines.push(` ${env.label}: ${env.hint}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
272
329
|
lines.push(...blockedSection(report, { limit }));
|
|
273
330
|
lines.push(...dockerSection(report));
|
|
274
331
|
lines.push(...errorsSection(report.errors));
|
|
@@ -310,7 +367,7 @@ function healthLines(records) {
|
|
|
310
367
|
if (!record.available) {
|
|
311
368
|
return ` ${record.envLabel}: processes could not be listed`;
|
|
312
369
|
}
|
|
313
|
-
return ` ${record.envLabel}: ${record.watched.length} watched processes still running`;
|
|
370
|
+
return ` ${record.envLabel}: ${record.watched.length} watched processes still running: ${record.watched.map((proc) => `${proc.name} (pid ${proc.pid})`).join(', ')}`;
|
|
314
371
|
});
|
|
315
372
|
return lines.length > 0 ? ['', 'Task health:', ...lines] : [];
|
|
316
373
|
}
|
|
@@ -358,6 +415,9 @@ export function formatAudit(audit, options = {}) {
|
|
|
358
415
|
'',
|
|
359
416
|
...limited(shown.map(entryLine), limit),
|
|
360
417
|
];
|
|
418
|
+
if (audit.aborted) {
|
|
419
|
+
lines.push(` cleanup aborted: ${audit.error ?? 'interrupted'}`);
|
|
420
|
+
}
|
|
361
421
|
if (hidden > 0) {
|
|
362
422
|
lines.push(` ${hidden} skipped (use --verbose to list them)`);
|
|
363
423
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/** Only regenerable cache leaves; profiles, databases and local storage survive. */
|
|
2
|
+
export const CACHE_LEAVES = [
|
|
3
|
+
'Cache',
|
|
4
|
+
'Code Cache',
|
|
5
|
+
'GPUCache',
|
|
6
|
+
'DawnGraphiteCache',
|
|
7
|
+
'DawnWebGPUCache',
|
|
8
|
+
'Service Worker/CacheStorage',
|
|
9
|
+
'Service Worker/ScriptCache',
|
|
10
|
+
];
|
|
11
|
+
const telegramAccounts =
|
|
12
|
+
'~/Library/Group Containers/*.ru.keepcoder.Telegram/*/account-*/postbox/media';
|
|
13
|
+
const telegramDesktop = [
|
|
14
|
+
'~/.local/share/TelegramDesktop/tdata',
|
|
15
|
+
'~/Library/Application Support/Telegram Desktop/tdata',
|
|
16
|
+
'{APPDATA}/Telegram Desktop/tdata',
|
|
17
|
+
];
|
|
18
|
+
export const APP_RULES = [
|
|
19
|
+
{
|
|
20
|
+
id: 'telegram-caches',
|
|
21
|
+
ecosystem: 'apps',
|
|
22
|
+
description: 'Telegram regenerable media cache partitions',
|
|
23
|
+
paths: [
|
|
24
|
+
...['cache', 'cache-storage', 'short-cache'].map(
|
|
25
|
+
(leaf) => `${telegramAccounts}/${leaf}`
|
|
26
|
+
),
|
|
27
|
+
'~/Library/Containers/ru.keepcoder.Telegram/Data/Library/Caches',
|
|
28
|
+
'~/Library/Caches/ru.keepcoder.Telegram',
|
|
29
|
+
...telegramDesktop.flatMap((root) =>
|
|
30
|
+
['cache', 'user_data/cache', 'user_data/media_cache'].map(
|
|
31
|
+
(leaf) => `${root}/${leaf}`
|
|
32
|
+
)
|
|
33
|
+
),
|
|
34
|
+
],
|
|
35
|
+
busy: ['Telegram', 'telegram-desktop'],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'telegram-downloaded-media',
|
|
39
|
+
excludeNames: ['cache', 'cache-storage', 'short-cache'],
|
|
40
|
+
ecosystem: 'apps',
|
|
41
|
+
tier: 'moderate',
|
|
42
|
+
description:
|
|
43
|
+
'Telegram downloaded media: secret-chat media may not be recoverable',
|
|
44
|
+
paths: [
|
|
45
|
+
`${telegramAccounts}/*`,
|
|
46
|
+
...telegramDesktop.map((root) => `${root}/user_data/downloads`),
|
|
47
|
+
],
|
|
48
|
+
busy: ['Telegram', 'telegram-desktop'],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'safari-webkit-caches',
|
|
52
|
+
ecosystem: 'browsers',
|
|
53
|
+
description: 'Safari and WebKit HTTP/compiled resource caches',
|
|
54
|
+
paths: [
|
|
55
|
+
'~/Library/Caches/com.apple.Safari',
|
|
56
|
+
'~/Library/Caches/com.apple.WebKit.Networking',
|
|
57
|
+
'~/Library/Caches/*/WebKit',
|
|
58
|
+
'~/Library/Containers/com.apple.Safari/Data/Library/Caches',
|
|
59
|
+
'~/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebsiteData/Default/CacheStorage',
|
|
60
|
+
'~/Library/WebKit/com.apple.Safari/WebsiteData/Default/CacheStorage',
|
|
61
|
+
],
|
|
62
|
+
busy: ['Safari', 'com.apple.WebKit'],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'electron-profile-caches',
|
|
66
|
+
ecosystem: 'apps',
|
|
67
|
+
description: 'Electron app regenerable caches',
|
|
68
|
+
paths: [
|
|
69
|
+
'~/Library/Application Support/*',
|
|
70
|
+
'~/.config/*',
|
|
71
|
+
'{APPDATA}/*',
|
|
72
|
+
].flatMap((root) => CACHE_LEAVES.map((leaf) => `${root}/${leaf}`)),
|
|
73
|
+
markers: ['Local Storage', 'Preferences'],
|
|
74
|
+
busy: ['electron', 'Electron'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'agent-scratch-sessions',
|
|
78
|
+
directoryOnly: true,
|
|
79
|
+
ecosystem: 'agents',
|
|
80
|
+
scope: 'tmp',
|
|
81
|
+
tier: 'moderate',
|
|
82
|
+
description:
|
|
83
|
+
'inactive Claude/Codex scratch sessions and partial work copies',
|
|
84
|
+
paths: [
|
|
85
|
+
'{TMP}/claude-*/*/*',
|
|
86
|
+
'{TMP}/codex-*/*/*',
|
|
87
|
+
'{TMP}/codex-scratch-*',
|
|
88
|
+
'{TMP}/claude-copy-*',
|
|
89
|
+
'{TMP}/codex-copy-*',
|
|
90
|
+
],
|
|
91
|
+
minAge: 'inactive',
|
|
92
|
+
git: 'ifPresent',
|
|
93
|
+
busy: ['claude', 'codex', 'git', 'node', 'bun'],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'agent-scratch-files',
|
|
97
|
+
ecosystem: 'agents',
|
|
98
|
+
scope: 'tmp',
|
|
99
|
+
tier: 'moderate',
|
|
100
|
+
description: 'inactive agent-named temporary logs and scripts',
|
|
101
|
+
paths: [
|
|
102
|
+
'{TMP}/claude-*.log',
|
|
103
|
+
'{TMP}/claude-*.sh',
|
|
104
|
+
'{TMP}/codex-*.log',
|
|
105
|
+
'{TMP}/codex-*.sh',
|
|
106
|
+
],
|
|
107
|
+
minAge: 'inactive',
|
|
108
|
+
busy: ['claude', 'codex', 'bash', 'sh'],
|
|
109
|
+
fileOnly: true,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'bunx-cache',
|
|
113
|
+
ecosystem: 'javascript',
|
|
114
|
+
description: 'per-entry bunx package cache',
|
|
115
|
+
scope: 'tmp',
|
|
116
|
+
paths: ['{TMP}/bunx-*'],
|
|
117
|
+
minAge: 'stale',
|
|
118
|
+
busy: [],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'pnpm-dlx-cache',
|
|
122
|
+
ecosystem: 'javascript',
|
|
123
|
+
description: 'per-entry pnpm dlx package cache',
|
|
124
|
+
paths: [
|
|
125
|
+
'~/.cache/pnpm/dlx/*',
|
|
126
|
+
'~/Library/Caches/pnpm/dlx/*',
|
|
127
|
+
'{LOCALAPPDATA}/pnpm-cache/dlx/*',
|
|
128
|
+
],
|
|
129
|
+
minAge: 'stale',
|
|
130
|
+
busy: [],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'yarn-dlx-cache',
|
|
134
|
+
ecosystem: 'javascript',
|
|
135
|
+
description: 'per-entry Yarn dlx temporary cache',
|
|
136
|
+
scope: 'tmp',
|
|
137
|
+
paths: ['{TMP}/dlx-*'],
|
|
138
|
+
minAge: 'stale',
|
|
139
|
+
busy: [],
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
export function expandChromiumCaches(rules) {
|
|
144
|
+
return rules.map((rule) => {
|
|
145
|
+
if (
|
|
146
|
+
!rule.id.includes('http-caches') ||
|
|
147
|
+
!rule.paths.some((p) => p.includes('/Code Cache'))
|
|
148
|
+
) {
|
|
149
|
+
return rule;
|
|
150
|
+
}
|
|
151
|
+
const bases = rule.paths
|
|
152
|
+
.filter((p) => p.endsWith('/Code Cache'))
|
|
153
|
+
.map((p) => p.slice(0, -'/Code Cache'.length));
|
|
154
|
+
const support = bases
|
|
155
|
+
.filter((p) => p.includes('~/Library/Caches/'))
|
|
156
|
+
.map((p) =>
|
|
157
|
+
p.replace('~/Library/Caches/', '~/Library/Application Support/')
|
|
158
|
+
);
|
|
159
|
+
const profiles = [...bases, ...support];
|
|
160
|
+
const roots = profiles.map((p) => (p.endsWith('/*') ? p.slice(0, -2) : p));
|
|
161
|
+
return {
|
|
162
|
+
...rule,
|
|
163
|
+
paths: [
|
|
164
|
+
...new Set([
|
|
165
|
+
...rule.paths,
|
|
166
|
+
...profiles.flatMap((p) =>
|
|
167
|
+
CACHE_LEAVES.map((leaf) => `${p}/${leaf}`)
|
|
168
|
+
),
|
|
169
|
+
...roots.flatMap((p) =>
|
|
170
|
+
['GrShaderCache', 'ShaderCache', 'component_crx_cache'].map(
|
|
171
|
+
(leaf) => `${p}/${leaf}`
|
|
172
|
+
)
|
|
173
|
+
),
|
|
174
|
+
]),
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
package/src/rules/ecosystems.js
CHANGED
|
@@ -31,15 +31,16 @@ export const ECOSYSTEMS = [
|
|
|
31
31
|
caches: [
|
|
32
32
|
{
|
|
33
33
|
id: 'npm-cache',
|
|
34
|
+
mtime: true,
|
|
34
35
|
description: 'npm download cache',
|
|
35
36
|
paths: ['~/.npm/_cacache', '{LOCALAPPDATA}/npm-cache/_cacache'],
|
|
36
|
-
native: { tool: 'npm', argv: ['npm', 'cache', 'clean', '--force'] },
|
|
37
37
|
busy: ['npm'],
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
id: 'npx-cache',
|
|
41
41
|
description: 'npx package cache',
|
|
42
|
-
paths: ['~/.npm/_npx', '{LOCALAPPDATA}/npm-cache/_npx'],
|
|
42
|
+
paths: ['~/.npm/_npx/*', '{LOCALAPPDATA}/npm-cache/_npx/*'],
|
|
43
|
+
minAge: 'stale',
|
|
43
44
|
busy: ['npm', 'npx'],
|
|
44
45
|
},
|
|
45
46
|
{
|
|
@@ -84,6 +85,7 @@ export const ECOSYSTEMS = [
|
|
|
84
85
|
},
|
|
85
86
|
{
|
|
86
87
|
id: 'node-gyp-cache',
|
|
88
|
+
mtime: true,
|
|
87
89
|
description: 'node-gyp headers cache',
|
|
88
90
|
paths: ['~/.cache/node-gyp', '~/Library/Caches/node-gyp'],
|
|
89
91
|
busy: ['node-gyp', 'npm'],
|
|
@@ -724,7 +726,6 @@ export const ECOSYSTEMS = [
|
|
|
724
726
|
id: 'opam-download-cache',
|
|
725
727
|
description: 'opam download cache',
|
|
726
728
|
paths: ['~/.opam/download-cache'],
|
|
727
|
-
native: { tool: 'opam', argv: ['opam', 'clean', '--download-cache'] },
|
|
728
729
|
busy: ['opam', 'dune', 'ocaml'],
|
|
729
730
|
},
|
|
730
731
|
],
|