disk-space-saviour 0.16.0 → 0.18.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/CHANGELOG.md +18 -0
- package/README.md +67 -14
- package/bin/dss.js +5 -1
- package/package.json +1 -1
- package/src/clean.js +45 -13
- package/src/cli.js +80 -14
- package/src/docker/scan.js +1 -0
- package/src/emergency.js +41 -6
- package/src/env/local.js +270 -65
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +10 -1
- package/src/fast-clean.js +39 -0
- package/src/git.js +101 -39
- package/src/health.js +3 -3
- package/src/index.d.ts +28 -1
- package/src/liveness.js +131 -9
- package/src/options.js +3 -1
- package/src/report.js +69 -2
- package/src/rules/ecosystems.js +5 -4
- package/src/rules/other.js +4 -1
- package/src/rules/versions.js +23 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +157 -22
- package/src/scanners/global.js +46 -11
- package/src/scanners/projects.js +94 -69
- package/src/scanners/versions.js +109 -7
- package/src/swap.js +69 -0
- package/src/timings.js +54 -0
package/src/git.js
CHANGED
|
@@ -36,36 +36,77 @@ const SAFE_GIT_ENV = {
|
|
|
36
36
|
GIT_TERMINAL_PROMPT: '0',
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The checkout's own Git directory (HEAD, index) and the common directory
|
|
41
|
+
* holding refs and objects. Both are `<root>/.git` in a plain repository;
|
|
42
|
+
* a linked worktree or a submodule has a `.git` file naming its directory,
|
|
43
|
+
* and a linked worktree names the common directory in `commondir`.
|
|
44
|
+
*/
|
|
45
|
+
async function gitDirectories(root) {
|
|
46
|
+
const dotGit = path.join(root, '.git');
|
|
47
|
+
const stat = await fsp.lstat(dotGit);
|
|
48
|
+
let gitDir = dotGit;
|
|
49
|
+
if (stat.isFile()) {
|
|
50
|
+
const named = /^gitdir: (.+)$/m.exec(await fsp.readFile(dotGit, 'utf8'));
|
|
51
|
+
if (!named) {
|
|
52
|
+
throw new Error(`${dotGit} does not name a Git directory`);
|
|
53
|
+
}
|
|
54
|
+
gitDir = path.resolve(root, named[1].trim());
|
|
55
|
+
} else if (!stat.isDirectory()) {
|
|
56
|
+
throw new Error(`${dotGit} is neither a directory nor a gitdir file`);
|
|
57
|
+
}
|
|
58
|
+
const commonDir = await fsp
|
|
59
|
+
.readFile(path.join(gitDir, 'commondir'), 'utf8')
|
|
60
|
+
.then(
|
|
61
|
+
(text) => path.resolve(gitDir, text.trim()),
|
|
62
|
+
(error) => {
|
|
63
|
+
if (error.code === 'ENOENT') {
|
|
64
|
+
return gitDir;
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
if (!(await fsp.stat(path.join(commonDir, 'objects'))).isDirectory()) {
|
|
70
|
+
throw new Error(`${commonDir} has no object database`);
|
|
71
|
+
}
|
|
72
|
+
return { gitDir, commonDir };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function copyGitMetadata(source, dir, names, force) {
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
await fsp
|
|
78
|
+
.cp(path.join(source, name), path.join(dir, name), {
|
|
79
|
+
recursive: true,
|
|
80
|
+
force,
|
|
81
|
+
errorOnExist: !force,
|
|
82
|
+
})
|
|
83
|
+
.catch((error) => {
|
|
84
|
+
if (error.code !== 'ENOENT') {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
45
88
|
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A metadata-only Git directory, with no repository-supplied config or hooks.
|
|
93
|
+
* With `indexOnly`, refs and reflogs are left out: enough for `ls-files`,
|
|
94
|
+
* which reads only HEAD, the index and the work tree.
|
|
95
|
+
*/
|
|
96
|
+
async function safeGitDirectory(root, callback, { indexOnly = false } = {}) {
|
|
97
|
+
const { gitDir, commonDir } = await gitDirectories(root);
|
|
46
98
|
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dss-safe-git-'));
|
|
47
99
|
try {
|
|
48
|
-
|
|
49
|
-
'HEAD',
|
|
50
|
-
'
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'refs',
|
|
54
|
-
'logs',
|
|
55
|
-
]) {
|
|
56
|
-
await fsp
|
|
57
|
-
.cp(path.join(source, name), path.join(dir, name), {
|
|
58
|
-
recursive: true,
|
|
59
|
-
force: false,
|
|
60
|
-
errorOnExist: true,
|
|
61
|
-
})
|
|
62
|
-
.catch((error) => {
|
|
63
|
-
if (error.code !== 'ENOENT') {
|
|
64
|
-
throw error;
|
|
65
|
-
}
|
|
66
|
-
});
|
|
100
|
+
if (indexOnly) {
|
|
101
|
+
await copyGitMetadata(gitDir, dir, ['HEAD', 'index'], false);
|
|
102
|
+
await fsp.mkdir(path.join(dir, 'refs'));
|
|
103
|
+
} else {
|
|
104
|
+
await copyIndexAndRefs(gitDir, commonDir, dir);
|
|
67
105
|
}
|
|
68
|
-
await fsp.symlink(
|
|
106
|
+
await fsp.symlink(
|
|
107
|
+
path.join(commonDir, 'objects'),
|
|
108
|
+
path.join(dir, 'objects')
|
|
109
|
+
);
|
|
69
110
|
await fsp.writeFile(
|
|
70
111
|
path.join(dir, 'config'),
|
|
71
112
|
'[core]\n\trepositoryformatversion = 0\n\tbare = false\n'
|
|
@@ -76,6 +117,24 @@ async function safeGitDirectory(root, callback) {
|
|
|
76
117
|
}
|
|
77
118
|
}
|
|
78
119
|
|
|
120
|
+
async function copyIndexAndRefs(gitDir, commonDir, dir) {
|
|
121
|
+
await copyGitMetadata(
|
|
122
|
+
commonDir,
|
|
123
|
+
dir,
|
|
124
|
+
['packed-refs', 'shallow', 'refs', 'logs'],
|
|
125
|
+
false
|
|
126
|
+
);
|
|
127
|
+
// Per-worktree state overrides the shared refs and reflogs.
|
|
128
|
+
await copyGitMetadata(
|
|
129
|
+
gitDir,
|
|
130
|
+
dir,
|
|
131
|
+
gitDir === commonDir
|
|
132
|
+
? ['HEAD', 'index']
|
|
133
|
+
: ['HEAD', 'index', 'refs', 'logs'],
|
|
134
|
+
true
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
79
138
|
function countLines(text) {
|
|
80
139
|
return text.split('\n').filter((line) => line.trim() !== '').length;
|
|
81
140
|
}
|
|
@@ -310,19 +369,22 @@ export class GitInspector {
|
|
|
310
369
|
try {
|
|
311
370
|
const result =
|
|
312
371
|
this.env.kind === 'host'
|
|
313
|
-
? await safeGitDirectory(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
372
|
+
? await safeGitDirectory(
|
|
373
|
+
root,
|
|
374
|
+
(safeDir) =>
|
|
375
|
+
this.#git(
|
|
376
|
+
root,
|
|
377
|
+
[
|
|
378
|
+
'ls-files',
|
|
379
|
+
'--cached',
|
|
380
|
+
'--others',
|
|
381
|
+
'--exclude-standard',
|
|
382
|
+
'--',
|
|
383
|
+
relative,
|
|
384
|
+
],
|
|
385
|
+
safeDir
|
|
386
|
+
),
|
|
387
|
+
{ indexOnly: true }
|
|
326
388
|
)
|
|
327
389
|
: await this.#git(root, [
|
|
328
390
|
'ls-files',
|
package/src/health.js
CHANGED
|
@@ -107,9 +107,9 @@ function describe(lost) {
|
|
|
107
107
|
* Per-environment snapshots and re-checks. `records` is the audit view.
|
|
108
108
|
*/
|
|
109
109
|
export class HealthWatch {
|
|
110
|
-
constructor() {
|
|
111
|
-
this.records = [];
|
|
112
|
-
this.byEnv = new Map();
|
|
110
|
+
constructor(records = []) {
|
|
111
|
+
this.records = [...records];
|
|
112
|
+
this.byEnv = new Map(records.map((record) => [record.env, record]));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
package/src/index.d.ts
CHANGED
|
@@ -13,19 +13,28 @@ export interface ScanOptions {
|
|
|
13
13
|
maxDepth?: number;
|
|
14
14
|
/** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
|
|
15
15
|
scanBudget?: Duration;
|
|
16
|
+
/** Persistent scan/index directory. Library default is false; CLI uses XDG state. */
|
|
17
|
+
cacheDir?: string | false;
|
|
18
|
+
/** Use verified Spotlight/locate hints before the discovery walk. */
|
|
19
|
+
platformIndex?: boolean;
|
|
16
20
|
signal?: AbortSignal;
|
|
17
21
|
onScanProgress?: (progress: {
|
|
18
22
|
env: string;
|
|
19
23
|
scanner: string;
|
|
20
24
|
elapsedMs: number;
|
|
21
25
|
items: number;
|
|
26
|
+
candidates: number;
|
|
27
|
+
filesSized: number;
|
|
28
|
+
directoriesSized: number;
|
|
29
|
+
indexHits: number;
|
|
30
|
+
sizeCacheHits: number;
|
|
22
31
|
status: string;
|
|
23
32
|
}) => void;
|
|
24
33
|
/** Activity window: newer files are kept (`1h`). */
|
|
25
34
|
staleAge?: Duration;
|
|
26
35
|
/** Alias of `staleAge`. */
|
|
27
36
|
olderThan?: Duration;
|
|
28
|
-
/**
|
|
37
|
+
/** Idle time before agent scratch and similar leftovers count as abandoned (`30d`). Project build and dependency directories do not depend on it. */
|
|
29
38
|
inactive?: Duration;
|
|
30
39
|
/** Items smaller than this are dropped (`1M`). */
|
|
31
40
|
minSize?: Size;
|
|
@@ -109,6 +118,8 @@ export interface Item {
|
|
|
109
118
|
path: string | null;
|
|
110
119
|
paths: string[];
|
|
111
120
|
bytes: number;
|
|
121
|
+
/** Cached size is advisory; cleanup always measures again. */
|
|
122
|
+
sizeEstimated?: boolean;
|
|
112
123
|
/** Merged filesystem bytes, including image data, when measured in Docker. */
|
|
113
124
|
totalBytes?: number;
|
|
114
125
|
/** Immutable image bytes excluded from reclaimable totals. */
|
|
@@ -154,9 +165,23 @@ export interface EnvironmentDescriptor {
|
|
|
154
165
|
chain: Array<{ containerId: string; name: string }>;
|
|
155
166
|
hint?: string | null;
|
|
156
167
|
disk?: Disk | null;
|
|
168
|
+
swap?: {
|
|
169
|
+
total: number;
|
|
170
|
+
used: number;
|
|
171
|
+
free: number;
|
|
172
|
+
holders: Array<{
|
|
173
|
+
pid: number;
|
|
174
|
+
command: string;
|
|
175
|
+
memoryBytes: number;
|
|
176
|
+
compressedBytes: number;
|
|
177
|
+
}>;
|
|
178
|
+
attribution: string;
|
|
179
|
+
advice: string;
|
|
180
|
+
} | null;
|
|
157
181
|
scanStatus?:
|
|
158
182
|
| 'scanning'
|
|
159
183
|
| 'complete'
|
|
184
|
+
| 'goal-reached'
|
|
160
185
|
| 'aborted'
|
|
161
186
|
| 'budget-exceeded'
|
|
162
187
|
| 'error'
|
|
@@ -178,6 +203,7 @@ export interface Report {
|
|
|
178
203
|
createdAt: string;
|
|
179
204
|
durationMs: number;
|
|
180
205
|
aborted?: boolean;
|
|
206
|
+
authentication?: { hostname: string; uid: number | string; digest: string };
|
|
181
207
|
audit?: { file: string | null; error: string; code?: string };
|
|
182
208
|
host: { env: string; label: string; platform: string };
|
|
183
209
|
options: Record<string, unknown>;
|
|
@@ -235,6 +261,7 @@ export interface AuditLog {
|
|
|
235
261
|
startedAt: string;
|
|
236
262
|
finishedAt: string | null;
|
|
237
263
|
aborted?: boolean;
|
|
264
|
+
report?: Partial<Report>;
|
|
238
265
|
error?: string;
|
|
239
266
|
entries: AuditEntry[];
|
|
240
267
|
freedBytes: number;
|
package/src/liveness.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
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
10
|
* - a command argument names a path inside the item (including scripts
|
|
11
|
-
* whose interpreter has already closed its input descriptor)
|
|
11
|
+
* whose interpreter has already closed its input descriptor);
|
|
12
|
+
* - a directory inside the item is on a process's `PATH`, so the process
|
|
13
|
+
* may start a program from it at any time.
|
|
12
14
|
*
|
|
13
15
|
* The cleaner refreshes the probe right before acting on each item.
|
|
14
16
|
*/
|
|
@@ -20,6 +22,44 @@ import { formatDuration } from './units.js';
|
|
|
20
22
|
|
|
21
23
|
const PROC_COMM_LIMIT = 15;
|
|
22
24
|
|
|
25
|
+
/** Bun subcommands that read or write the install cache. */
|
|
26
|
+
const BUN_PACKAGE_COMMANDS = new Set([
|
|
27
|
+
'install',
|
|
28
|
+
'i',
|
|
29
|
+
'add',
|
|
30
|
+
'a',
|
|
31
|
+
'remove',
|
|
32
|
+
'rm',
|
|
33
|
+
'update',
|
|
34
|
+
'upgrade',
|
|
35
|
+
'outdated',
|
|
36
|
+
'link',
|
|
37
|
+
'unlink',
|
|
38
|
+
'pm',
|
|
39
|
+
'x',
|
|
40
|
+
'create',
|
|
41
|
+
'c',
|
|
42
|
+
'init',
|
|
43
|
+
'patch',
|
|
44
|
+
'publish',
|
|
45
|
+
'audit',
|
|
46
|
+
'why',
|
|
47
|
+
'info',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function isBun(proc) {
|
|
51
|
+
return Boolean(matchingName(proc, ['bun', 'bunx']));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bunPackageCommand(proc) {
|
|
55
|
+
const args = processArgs(proc);
|
|
56
|
+
if (path.posix.basename(args[0] ?? '') === 'bunx' || proc.name === 'bunx') {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
const command = args.slice(1).find((arg) => !arg.startsWith('-'));
|
|
60
|
+
return BUN_PACKAGE_COMMANDS.has(command);
|
|
61
|
+
}
|
|
62
|
+
|
|
23
63
|
function uncertaintyRoots(proc) {
|
|
24
64
|
const roots = processArgs(proc).flatMap((arg) => {
|
|
25
65
|
const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
|
|
@@ -182,6 +222,7 @@ export class LivenessProbe {
|
|
|
182
222
|
const processes = await this.env.processes().catch(() => null);
|
|
183
223
|
this.listed = processes !== null;
|
|
184
224
|
this.processes = processes ?? [];
|
|
225
|
+
this.bunCacheUsers = await this.#bunCacheUsers();
|
|
185
226
|
this.probeError =
|
|
186
227
|
(processes === null || this.openPaths === null) &&
|
|
187
228
|
(this.env.platform === 'linux' || this.env.platform === 'darwin');
|
|
@@ -191,6 +232,49 @@ export class LivenessProbe {
|
|
|
191
232
|
this.pathOwners.clear();
|
|
192
233
|
}
|
|
193
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Pids of Bun processes that may use the install cache: package commands,
|
|
237
|
+
* and anything else (scripts, tests, the REPL) without a `node_modules`
|
|
238
|
+
* directory above its working directory, because Bun then auto-installs
|
|
239
|
+
* from the cache. A long-running script of an installed project does not
|
|
240
|
+
* read the cache, so it does not keep the cache busy.
|
|
241
|
+
*/
|
|
242
|
+
async #bunCacheUsers() {
|
|
243
|
+
const users = new Set();
|
|
244
|
+
const installed = new Map();
|
|
245
|
+
const hasModules = async (dir) => {
|
|
246
|
+
if (!installed.has(dir)) {
|
|
247
|
+
installed.set(
|
|
248
|
+
dir,
|
|
249
|
+
Boolean(
|
|
250
|
+
await this.env.exists?.(this.env.path.join(dir, 'node_modules'))
|
|
251
|
+
)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return installed.get(dir);
|
|
255
|
+
};
|
|
256
|
+
for (const proc of this.processes) {
|
|
257
|
+
if (!isBun(proc)) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (bunPackageCommand(proc) || !proc.cwd || !this.env.exists) {
|
|
261
|
+
users.add(proc.pid);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
let found = false;
|
|
265
|
+
for (const dir of [proc.cwd, ...ancestors(proc.cwd, this.env.path)]) {
|
|
266
|
+
if (await hasModules(dir)) {
|
|
267
|
+
found = true;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!found) {
|
|
272
|
+
users.add(proc.pid);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return users;
|
|
276
|
+
}
|
|
277
|
+
|
|
194
278
|
/** Processes of the last refresh, or null when they could not be listed. */
|
|
195
279
|
processList() {
|
|
196
280
|
return this.listed ? this.processes : null;
|
|
@@ -269,6 +353,13 @@ export class LivenessProbe {
|
|
|
269
353
|
) {
|
|
270
354
|
continue;
|
|
271
355
|
}
|
|
356
|
+
if (
|
|
357
|
+
item.rule === 'bun-cache' &&
|
|
358
|
+
isBun(proc) &&
|
|
359
|
+
!this.bunCacheUsers?.has(proc.pid)
|
|
360
|
+
) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
272
363
|
const name = matchingName(proc, names);
|
|
273
364
|
if (!name) {
|
|
274
365
|
continue;
|
|
@@ -291,10 +382,7 @@ export class LivenessProbe {
|
|
|
291
382
|
return null;
|
|
292
383
|
}
|
|
293
384
|
const pathApi = this.env.path;
|
|
294
|
-
const targets =
|
|
295
|
-
const real = this.realPaths.get(target);
|
|
296
|
-
return real && real !== target ? [target, real] : [target];
|
|
297
|
-
});
|
|
385
|
+
const targets = this.withRealPaths(paths);
|
|
298
386
|
for (const open of this.openPaths) {
|
|
299
387
|
for (const target of targets) {
|
|
300
388
|
if (isWithin(open, target, pathApi)) {
|
|
@@ -340,6 +428,26 @@ export class LivenessProbe {
|
|
|
340
428
|
return null;
|
|
341
429
|
}
|
|
342
430
|
|
|
431
|
+
/** `paths` plus their resolved forms, as processes report paths. */
|
|
432
|
+
withRealPaths(paths) {
|
|
433
|
+
return paths.flatMap((target) => {
|
|
434
|
+
const real = this.realPaths.get(target);
|
|
435
|
+
return real && real !== target ? [target, real] : [target];
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
searchPathUsage(paths) {
|
|
440
|
+
const targets = this.withRealPaths(paths);
|
|
441
|
+
for (const proc of this.processes) {
|
|
442
|
+
for (const dir of proc.searchPath ?? []) {
|
|
443
|
+
if (targets.some((target) => isWithin(dir, target, this.env.path))) {
|
|
444
|
+
return `on the PATH of ${proc.name || 'a process'} (pid ${proc.pid}): ${dir}`;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
343
451
|
uncertainUsage(paths) {
|
|
344
452
|
for (const proc of this.env.unreadableProcesses ?? []) {
|
|
345
453
|
const args = processArgs(proc);
|
|
@@ -399,14 +507,28 @@ export class LivenessProbe {
|
|
|
399
507
|
const paths = checks.perPath ? [] : (item.paths ?? []);
|
|
400
508
|
return (
|
|
401
509
|
this.installationBlocker(item.paths ?? []) ??
|
|
402
|
-
|
|
403
|
-
? `cannot verify process and open-file activity${this.env.probeDiagnostic ? `: ${this.env.probeDiagnostic}` : ''}`
|
|
404
|
-
: null) ??
|
|
510
|
+
this.probeBlocker() ??
|
|
405
511
|
(checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
|
|
406
512
|
this.runningTool(checks.busy, checks.cwd, item) ??
|
|
513
|
+
this.processUsage(paths) ??
|
|
514
|
+
this.uncertainUsage(item.paths ?? [])
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
probeBlocker() {
|
|
519
|
+
if (!this.probeError) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const diagnostic = this.env.probeDiagnostic;
|
|
523
|
+
return `cannot verify process and open-file activity${diagnostic ? `: ${diagnostic}` : ''}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Open files, command arguments and PATH entries inside the paths. */
|
|
527
|
+
processUsage(paths) {
|
|
528
|
+
return (
|
|
407
529
|
this.openInside(paths) ??
|
|
408
530
|
this.commandUsage(paths) ??
|
|
409
|
-
this.
|
|
531
|
+
this.searchPathUsage(paths)
|
|
410
532
|
);
|
|
411
533
|
}
|
|
412
534
|
|
package/src/options.js
CHANGED
|
@@ -16,7 +16,7 @@ export const DEFAULTS = {
|
|
|
16
16
|
docker: null,
|
|
17
17
|
dockerDepth: 3,
|
|
18
18
|
containers: [],
|
|
19
|
-
scanners: ['
|
|
19
|
+
scanners: ['global', 'projects', 'versions', 'agents', 'system'],
|
|
20
20
|
only: [],
|
|
21
21
|
exclude: [],
|
|
22
22
|
includeVolumes: false,
|
|
@@ -33,6 +33,8 @@ export const DEFAULTS = {
|
|
|
33
33
|
backupDir: null,
|
|
34
34
|
auditDir: null,
|
|
35
35
|
scanBudget: '2m',
|
|
36
|
+
cacheDir: false,
|
|
37
|
+
platformIndex: true,
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
function toBytes(value) {
|
package/src/report.js
CHANGED
|
@@ -56,7 +56,7 @@ function itemLine(item, showEnv) {
|
|
|
56
56
|
: item.imageBytes
|
|
57
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
58
|
: '';
|
|
59
|
-
return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
|
|
59
|
+
return ` ${pad(formatBytes(item.bytes), 10)}${item.sizeEstimated ? ' (estimated)' : ''} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function limited(lines, limit) {
|
|
@@ -305,6 +305,52 @@ function errorsSection(errors) {
|
|
|
305
305
|
];
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
const TIMING_LIMIT = 5;
|
|
309
|
+
|
|
310
|
+
function phaseList(phases) {
|
|
311
|
+
return phases
|
|
312
|
+
.map(
|
|
313
|
+
(phase) =>
|
|
314
|
+
`${phase.phase} ${(phase.ms / 1000).toFixed(1)}s${phase.count > 1 ? ` ×${phase.count}` : ''}`
|
|
315
|
+
)
|
|
316
|
+
.join(', ');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Slowest cleanup phases and items. `audit.timings` is a live PhaseTimer
|
|
321
|
+
* while cleaning and a plain array once read back from JSON.
|
|
322
|
+
*/
|
|
323
|
+
function auditTimingLines(audit, { verbose }) {
|
|
324
|
+
const phases = audit.timings?.toJSON?.() ?? audit.timings ?? [];
|
|
325
|
+
const items = audit.entries
|
|
326
|
+
.filter((entry) => entry.durationMs > 0)
|
|
327
|
+
.sort((a, b) => b.durationMs - a.durationMs);
|
|
328
|
+
const limit = verbose ? Infinity : TIMING_LIMIT;
|
|
329
|
+
const lines = [];
|
|
330
|
+
if (phases.length > 0) {
|
|
331
|
+
lines.push(` slowest phases: ${phaseList(phases.slice(0, limit))}`);
|
|
332
|
+
}
|
|
333
|
+
for (const entry of items.slice(0, limit)) {
|
|
334
|
+
lines.push(
|
|
335
|
+
` ${(entry.durationMs / 1000).toFixed(1)}s ${entry.rule} ${entry.path ?? entry.description}`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return lines.length > 0 ? ['', 'TIMINGS', ...lines] : [];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The slowest phases of each environment, so bottlenecks are visible
|
|
343
|
+
* without debug flags.
|
|
344
|
+
*/
|
|
345
|
+
function timingLines(report, { verbose }) {
|
|
346
|
+
return report.environments
|
|
347
|
+
.filter((env) => env.timings?.length > 0)
|
|
348
|
+
.map((env) => {
|
|
349
|
+
const phases = verbose ? env.timings : env.timings.slice(0, TIMING_LIMIT);
|
|
350
|
+
return ` ${env.label} slowest phases: ${phaseList(phases)}`;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
308
354
|
/**
|
|
309
355
|
* Renders a scan report.
|
|
310
356
|
* @param {object} report
|
|
@@ -318,6 +364,18 @@ export function formatReport(report, options = {}) {
|
|
|
318
364
|
`disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
|
|
319
365
|
formatDisk(host.disk),
|
|
320
366
|
];
|
|
367
|
+
if (host.swap) {
|
|
368
|
+
lines.push(
|
|
369
|
+
`Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
|
|
370
|
+
);
|
|
371
|
+
lines.push(host.swap.attribution);
|
|
372
|
+
for (const holder of host.swap.holders) {
|
|
373
|
+
lines.push(
|
|
374
|
+
` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
lines.push(host.swap.advice);
|
|
378
|
+
}
|
|
321
379
|
for (const tier of TIERS) {
|
|
322
380
|
lines.push(...tierSection(report, tier, { limit }));
|
|
323
381
|
}
|
|
@@ -325,7 +383,11 @@ export function formatReport(report, options = {}) {
|
|
|
325
383
|
if (env.layerSource) {
|
|
326
384
|
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
327
385
|
}
|
|
328
|
-
if (env.scanStatus
|
|
386
|
+
if (env.scanStatus === 'goal-reached') {
|
|
387
|
+
lines.push(
|
|
388
|
+
` ${env.label}: scan stopped after the free-space goal was met`
|
|
389
|
+
);
|
|
390
|
+
} else if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
329
391
|
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
330
392
|
}
|
|
331
393
|
if (env.hint) {
|
|
@@ -336,6 +398,10 @@ export function formatReport(report, options = {}) {
|
|
|
336
398
|
lines.push(...dockerSection(report));
|
|
337
399
|
lines.push(...errorsSection(report.errors));
|
|
338
400
|
lines.push(...totalsSection(report));
|
|
401
|
+
const timings = timingLines(report, options);
|
|
402
|
+
if (timings.length > 0) {
|
|
403
|
+
lines.push('', 'TIMINGS', ...timings);
|
|
404
|
+
}
|
|
339
405
|
return lines.join('\n');
|
|
340
406
|
}
|
|
341
407
|
|
|
@@ -429,6 +495,7 @@ export function formatAudit(audit, options = {}) {
|
|
|
429
495
|
}
|
|
430
496
|
lines.push('', 'Per environment:', ...environmentLines(audit.environments));
|
|
431
497
|
lines.push(...healthLines(audit.health));
|
|
498
|
+
lines.push(...auditTimingLines(audit, options));
|
|
432
499
|
lines.push(
|
|
433
500
|
'',
|
|
434
501
|
audit.dryRun
|
package/src/rules/ecosystems.js
CHANGED
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* `projects` are directories inside project trees, recognised by name plus a
|
|
10
10
|
* marker file next to them (`markers`), inside them (`selfMarkers`), or next
|
|
11
|
-
* to their parent (`parentMarkers`, with `parentName`).
|
|
12
|
-
* `
|
|
13
|
-
* inactive for the configured number of days and `aggressive` otherwise.
|
|
11
|
+
* to their parent (`parentMarkers`, with `parentName`). All kinds (`cache`,
|
|
12
|
+
* `build`, `dependencies`) are `safe` whenever nothing is using them.
|
|
14
13
|
*
|
|
15
14
|
* `busy` lists process names that, when running (inside the project for
|
|
16
15
|
* project directories), mean the tool may be using the directory right now.
|
|
@@ -71,7 +70,9 @@ export const ECOSYSTEMS = [
|
|
|
71
70
|
description: 'Bun install cache',
|
|
72
71
|
paths: ['~/.bun/install/cache'],
|
|
73
72
|
native: { tool: 'bun', argv: ['bun', 'pm', 'cache', 'rm'] },
|
|
74
|
-
|
|
73
|
+
// Only package commands and auto-installing scripts read the cache
|
|
74
|
+
// (see LivenessProbe.refresh).
|
|
75
|
+
busy: ['bun', 'bunx'],
|
|
75
76
|
},
|
|
76
77
|
{
|
|
77
78
|
id: 'deno-cache',
|
package/src/rules/other.js
CHANGED
|
@@ -349,8 +349,11 @@ export const OTHER_RULES = [
|
|
|
349
349
|
ecosystem: 'agents',
|
|
350
350
|
description: 'abandoned Codex runtime install staging directories',
|
|
351
351
|
paths: ['~/.cache/codex-runtimes/codex-runtime-install-*'],
|
|
352
|
+
// The Codex daemon runs for days, so a running `codex` says nothing
|
|
353
|
+
// about a staging directory: an install in progress writes to it and
|
|
354
|
+
// holds its files open, which the activity window and open-file checks
|
|
355
|
+
// catch.
|
|
352
356
|
minAge: 'stale',
|
|
353
|
-
busy: ['codex'],
|
|
354
357
|
},
|
|
355
358
|
{
|
|
356
359
|
id: 'claude-cli-mcp-logs',
|
package/src/rules/versions.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* - the newest installed version,
|
|
7
7
|
* - in use by a running process (checked by the liveness layer).
|
|
8
8
|
*
|
|
9
|
+
* `refsPickNewest` makes a partial ref keep only the newest install it
|
|
10
|
+
* matches (nvm runs the newest 20.x for `20`); otherwise it keeps them all.
|
|
11
|
+
* `globalPackages` blocks versions holding packages the user installed
|
|
12
|
+
* globally, which reinstalling the version would not bring back.
|
|
13
|
+
*
|
|
9
14
|
* `versionOf(dirPath)` maps an install directory to the version string that
|
|
10
15
|
* defaults and pin files are compared with (see `versionMatches`).
|
|
11
16
|
*/
|
|
@@ -112,6 +117,16 @@ export const VERSION_RULES = [
|
|
|
112
117
|
busy: ['claude'],
|
|
113
118
|
recheck: true,
|
|
114
119
|
},
|
|
120
|
+
{
|
|
121
|
+
id: 'codex-daemon-releases',
|
|
122
|
+
ecosystem: 'agents',
|
|
123
|
+
manager: 'Codex',
|
|
124
|
+
description: 'Codex app-server daemon release',
|
|
125
|
+
dirs: ['~/.codex/packages/app-server-daemon/releases/*'],
|
|
126
|
+
defaultLinks: ['~/.codex/packages/app-server-daemon/current'],
|
|
127
|
+
busy: ['codex'],
|
|
128
|
+
recheck: true,
|
|
129
|
+
},
|
|
115
130
|
{
|
|
116
131
|
id: 'copilot-cli-versions',
|
|
117
132
|
ecosystem: 'agents',
|
|
@@ -133,6 +148,14 @@ export const VERSION_RULES = [
|
|
|
133
148
|
aliasDir: '~/.nvm/alias',
|
|
134
149
|
defaults: [{ path: '~/.nvm/alias/default', parse: firstLines }],
|
|
135
150
|
projectFiles: { '.nvmrc': firstLines, '.node-version': firstLines },
|
|
151
|
+
refsPickNewest: true,
|
|
152
|
+
globalPackages: {
|
|
153
|
+
dir: 'lib/node_modules',
|
|
154
|
+
// Bundled with Node.js, or a use-m cache (`<name>-v-<version>`) that
|
|
155
|
+
// use-m installs again on first use.
|
|
156
|
+
regenerable: (name) =>
|
|
157
|
+
name === 'npm' || name === 'corepack' || /^[^@][^/]*-v-./.test(name),
|
|
158
|
+
},
|
|
136
159
|
busy: ['node'],
|
|
137
160
|
},
|
|
138
161
|
{
|