disk-space-saviour 0.15.2 → 0.17.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.
@@ -0,0 +1,39 @@
1
+ /** Clean completed safe host findings while discovery continues. */
2
+ import { clean } from './clean.js';
3
+ import { scan } from './scan.js';
4
+
5
+ export async function cleanDuringScan(options) {
6
+ const entries = [];
7
+ const health = [];
8
+ let failure;
9
+ const report = await scan({
10
+ ...options,
11
+ onScanPhase: async (phase) => {
12
+ try {
13
+ const audit = await clean(phase, {
14
+ ...options,
15
+ tier: 'safe',
16
+ priorEntries: entries,
17
+ priorHealth: health,
18
+ });
19
+ entries.splice(0, entries.length, ...audit.entries);
20
+ health.splice(0, health.length, ...audit.health);
21
+ } catch (error) {
22
+ failure = error;
23
+ return false;
24
+ }
25
+ },
26
+ });
27
+ if (failure) {
28
+ throw failure;
29
+ }
30
+ if (report.aborted) {
31
+ return { report, audit: null };
32
+ }
33
+ const audit = await clean(report, {
34
+ ...options,
35
+ priorEntries: entries,
36
+ priorHealth: health,
37
+ });
38
+ return { report, audit };
39
+ }
package/src/health.js CHANGED
@@ -52,8 +52,14 @@ function identity(proc) {
52
52
  * Processes worth watching: PID 1 and every agent or build tool.
53
53
  * @param {object[]} processes output of `env.processes()`
54
54
  */
55
- export function watchedProcesses(processes) {
55
+ export function watchedProcesses(processes, mountNamespace = null) {
56
56
  return processes
57
+ .filter(
58
+ (proc) =>
59
+ !mountNamespace ||
60
+ !proc.mountNamespace ||
61
+ proc.mountNamespace === mountNamespace
62
+ )
57
63
  .filter((proc) => proc.pid === 1 || matchingName(proc, WATCHED_NAMES))
58
64
  .map(identity);
59
65
  }
@@ -101,9 +107,9 @@ function describe(lost) {
101
107
  * Per-environment snapshots and re-checks. `records` is the audit view.
102
108
  */
103
109
  export class HealthWatch {
104
- constructor() {
105
- this.records = [];
106
- this.byEnv = new Map();
110
+ constructor(records = []) {
111
+ this.records = [...records];
112
+ this.byEnv = new Map(records.map((record) => [record.env, record]));
107
113
  }
108
114
 
109
115
  /**
@@ -118,7 +124,12 @@ export class HealthWatch {
118
124
  env: item.env,
119
125
  envLabel: item.envLabel,
120
126
  checks: 0,
121
- watched: processes ? watchedProcesses(processes) : [],
127
+ watched: processes
128
+ ? watchedProcesses(
129
+ processes,
130
+ env.kind === 'host' ? env.mountNamespace : null
131
+ )
132
+ : [],
122
133
  available: Boolean(processes),
123
134
  lost: [],
124
135
  stopped: null,
package/src/index.d.ts CHANGED
@@ -11,6 +11,25 @@ export interface ScanOptions {
11
11
  roots?: string[] | string | null;
12
12
  /** Directory depth for project search. */
13
13
  maxDepth?: number;
14
+ /** Time budget per filesystem environment (`2m`); incomplete findings are kept. */
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;
20
+ signal?: AbortSignal;
21
+ onScanProgress?: (progress: {
22
+ env: string;
23
+ scanner: string;
24
+ elapsedMs: number;
25
+ items: number;
26
+ candidates: number;
27
+ filesSized: number;
28
+ directoriesSized: number;
29
+ indexHits: number;
30
+ sizeCacheHits: number;
31
+ status: string;
32
+ }) => void;
14
33
  /** Activity window: newer files are kept (`1h`). */
15
34
  staleAge?: Duration;
16
35
  /** Alias of `staleAge`. */
@@ -65,7 +84,6 @@ export interface CleanOptions extends ScanOptions {
65
84
  confirm?: (item: Item) => boolean | Promise<boolean>;
66
85
  /** `false` skips writing the audit log file. */
67
86
  audit?: boolean;
68
- signal?: AbortSignal;
69
87
  onEntry?: (entry: AuditEntry, item: Item) => void;
70
88
  }
71
89
 
@@ -100,6 +118,8 @@ export interface Item {
100
118
  path: string | null;
101
119
  paths: string[];
102
120
  bytes: number;
121
+ /** Cached size is advisory; cleanup always measures again. */
122
+ sizeEstimated?: boolean;
103
123
  /** Merged filesystem bytes, including image data, when measured in Docker. */
104
124
  totalBytes?: number;
105
125
  /** Immutable image bytes excluded from reclaimable totals. */
@@ -145,11 +165,36 @@ export interface EnvironmentDescriptor {
145
165
  chain: Array<{ containerId: string; name: string }>;
146
166
  hint?: string | null;
147
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;
181
+ scanStatus?:
182
+ | 'scanning'
183
+ | 'complete'
184
+ | 'goal-reached'
185
+ | 'aborted'
186
+ | 'budget-exceeded'
187
+ | 'error'
188
+ | null;
189
+ layerSource?: string | null;
148
190
  }
149
191
 
150
192
  export interface TierTotal {
151
193
  items: number;
152
194
  bytes: number;
195
+ /** Visible blocked bytes, including data whose writable size is unknown. */
196
+ totalBytes?: number;
197
+ unknownBytes?: number;
153
198
  }
154
199
 
155
200
  export interface Report {
@@ -157,6 +202,9 @@ export interface Report {
157
202
  tool: 'disk-space-saviour';
158
203
  createdAt: string;
159
204
  durationMs: number;
205
+ aborted?: boolean;
206
+ authentication?: { hostname: string; uid: number | string; digest: string };
207
+ audit?: { file: string | null; error: string; code?: string };
160
208
  host: { env: string; label: string; platform: string };
161
209
  options: Record<string, unknown>;
162
210
  environments: EnvironmentDescriptor[];
@@ -188,6 +236,8 @@ export interface AuditEntry {
188
236
  reason: string | null;
189
237
  plannedBytes: number;
190
238
  freedBytes: number;
239
+ /** True when authoritative before/after allocation could not be measured. */
240
+ freedEstimated?: boolean;
191
241
  durationMs: number;
192
242
  backup?: string;
193
243
  [key: string]: unknown;
@@ -211,6 +261,7 @@ export interface AuditLog {
211
261
  startedAt: string;
212
262
  finishedAt: string | null;
213
263
  aborted?: boolean;
264
+ report?: Partial<Report>;
214
265
  error?: string;
215
266
  entries: AuditEntry[];
216
267
  freedBytes: number;
package/src/items.js CHANGED
@@ -100,11 +100,17 @@ export function tierTotals(items) {
100
100
  Boolean(item.requiresConfirmation)
101
101
  )
102
102
  );
103
+ const blocked = dropNested(items.filter((item) => item.blockers.length > 0));
103
104
  totals.blocked = {
104
105
  items: items.filter((item) => item.blockers.length > 0).length,
105
- bytes: items
106
- .filter((item) => item.blockers.length > 0)
107
- .reduce((sum, item) => sum + item.bytes, 0),
106
+ bytes: blocked.reduce((sum, item) => sum + item.bytes, 0),
107
+ totalBytes: blocked.reduce(
108
+ (sum, item) => sum + (item.totalBytes ?? item.bytes),
109
+ 0
110
+ ),
111
+ unknownBytes: blocked
112
+ .filter((item) => item.sizeUnknown)
113
+ .reduce((sum, item) => sum + (item.totalBytes ?? item.bytes), 0),
108
114
  };
109
115
  return totals;
110
116
  }
package/src/liveness.js CHANGED
@@ -20,6 +20,46 @@ import { formatDuration } from './units.js';
20
20
 
21
21
  const PROC_COMM_LIMIT = 15;
22
22
 
23
+ function uncertaintyRoots(proc) {
24
+ const roots = processArgs(proc).flatMap((arg) => {
25
+ const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
26
+ return value.startsWith('/') ? [value] : [];
27
+ });
28
+ if (proc.name === 'dockerd') {
29
+ roots.push('/var/lib/docker', '/run');
30
+ }
31
+ if (proc.name === 'containerd') {
32
+ roots.push('/var/lib/containerd', '/run/containerd');
33
+ }
34
+ return roots;
35
+ }
36
+
37
+ function couldAccess(proc, stat) {
38
+ return (
39
+ proc.uid === 0 ||
40
+ typeof proc.uid !== 'number' ||
41
+ !stat ||
42
+ stat.uid === undefined ||
43
+ stat.uid === proc.uid ||
44
+ stat.mode === undefined ||
45
+ (stat.mode & 0o007) !== 0 ||
46
+ // Group bits can also be an ACL mask granting another named identity.
47
+ (stat.mode & 0o070) !== 0
48
+ );
49
+ }
50
+
51
+ function ancestors(target, pathApi) {
52
+ const result = [];
53
+ for (
54
+ let dir = pathApi.dirname(target);
55
+ dir !== pathApi.dirname(dir);
56
+ dir = pathApi.dirname(dir)
57
+ ) {
58
+ result.push(dir);
59
+ }
60
+ return result;
61
+ }
62
+
23
63
  function nameMatches(name, candidate) {
24
64
  return (
25
65
  name === candidate ||
@@ -131,6 +171,7 @@ export class LivenessProbe {
131
171
  this.probeError = false;
132
172
  this.realPaths = new Map();
133
173
  this.pathOwners = new Map();
174
+ this.pathStats = new Map();
134
175
  }
135
176
 
136
177
  async refresh(force = false) {
@@ -145,6 +186,9 @@ export class LivenessProbe {
145
186
  (processes === null || this.openPaths === null) &&
146
187
  (this.env.platform === 'linux' || this.env.platform === 'darwin');
147
188
  this.refreshedAt = this.now();
189
+ this.realPaths.clear();
190
+ this.pathStats.clear();
191
+ this.pathOwners.clear();
148
192
  }
149
193
 
150
194
  /** Processes of the last refresh, or null when they could not be listed. */
@@ -159,18 +203,46 @@ export class LivenessProbe {
159
203
  * @param {string[]} paths
160
204
  */
161
205
  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);
206
+ if (this.env.unreadableProcesses?.length) {
207
+ const missing = [
208
+ ...new Set(
209
+ paths.flatMap((target) => [
210
+ target,
211
+ ...ancestors(target, this.env.path),
212
+ ])
213
+ ),
214
+ ].filter((target) => !this.pathStats.has(target));
215
+ const stats = this.env.statMany
216
+ ? await this.env.statMany(missing)
217
+ : new Map(
218
+ await Promise.all(
219
+ missing.map(async (target) => [
220
+ target,
221
+ await this.env.stat?.(target),
222
+ ])
223
+ )
224
+ );
225
+ for (const target of missing) {
226
+ this.pathStats.set(target, stats.get(target));
227
+ this.pathOwners.set(target, stats.get(target)?.uid);
165
228
  }
166
229
  }
167
- if (typeof this.env.realPath !== 'function') {
230
+ if (!this.env.realPath && !this.env.realPathMany) {
168
231
  return;
169
232
  }
170
- for (const target of paths) {
171
- if (!this.realPaths.has(target)) {
172
- this.realPaths.set(target, await this.env.realPath(target));
173
- }
233
+ const missing = paths.filter((target) => !this.realPaths.has(target));
234
+ const resolved = this.env.realPathMany
235
+ ? await this.env.realPathMany(missing)
236
+ : new Map(
237
+ await Promise.all(
238
+ missing.map(async (target) => [
239
+ target,
240
+ await this.env.realPath(target),
241
+ ])
242
+ )
243
+ );
244
+ for (const target of missing) {
245
+ this.realPaths.set(target, resolved.get(target) ?? null);
174
246
  }
175
247
  }
176
248
 
@@ -271,29 +343,25 @@ export class LivenessProbe {
271
343
  uncertainUsage(paths) {
272
344
  for (const proc of this.env.unreadableProcesses ?? []) {
273
345
  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
- }
346
+ const roots = uncertaintyRoots(proc);
347
+ const scopedDaemon =
348
+ proc.uid === 0 &&
349
+ ['dockerd', 'containerd'].includes(proc.name) &&
350
+ args.length > 0;
284
351
  for (const target of paths) {
285
352
  const intersects = roots.some(
286
353
  (root) =>
287
354
  isWithin(root, target, this.env.path) ||
288
355
  isWithin(target, root, this.env.path)
289
356
  );
290
- const owner = this.pathOwners.get(target);
291
- if (
292
- intersects ||
293
- proc.uid === null ||
294
- owner === undefined ||
295
- owner === proc.uid
296
- ) {
357
+ if (scopedDaemon && !intersects) {
358
+ continue;
359
+ }
360
+ if (!intersects && this.inaccessible(proc, target)) {
361
+ continue;
362
+ }
363
+ const stat = this.pathStats.get(target);
364
+ if (intersects || couldAccess(proc, stat)) {
297
365
  return `unreadable process ${proc.name || 'unknown'} (pid ${proc.pid}, uid ${proc.uid ?? 'unknown'}) may use ${target}`;
298
366
  }
299
367
  }
@@ -301,6 +369,25 @@ export class LivenessProbe {
301
369
  return null;
302
370
  }
303
371
 
372
+ inaccessible(proc, target) {
373
+ if (proc.uid === 0 || typeof proc.uid !== 'number') {
374
+ return false;
375
+ }
376
+ return ancestors(target, this.env.path).some((dir) => {
377
+ const stat = this.pathStats.get(dir);
378
+ if (
379
+ !stat ||
380
+ stat.mode === undefined ||
381
+ stat.uid === proc.uid ||
382
+ stat.mode & 0o001
383
+ ) {
384
+ return false;
385
+ }
386
+ const groupAccess = stat.mode & 0o010;
387
+ return !groupAccess;
388
+ });
389
+ }
390
+
304
391
  /**
305
392
  * First reason the item is busy, or null when it is idle. Items with
306
393
  * `checks.perPath` leave open files to `openPathsOf`: the caller skips
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: ['projects', 'global', 'versions', 'agents', 'system'],
19
+ scanners: ['global', 'projects', 'versions', 'agents', 'system'],
20
20
  only: [],
21
21
  exclude: [],
22
22
  includeVolumes: false,
@@ -32,6 +32,9 @@ export const DEFAULTS = {
32
32
  yes: false,
33
33
  backupDir: null,
34
34
  auditDir: null,
35
+ scanBudget: '2m',
36
+ cacheDir: false,
37
+ platformIndex: true,
35
38
  };
36
39
 
37
40
  function toBytes(value) {
@@ -70,6 +73,10 @@ export function resolveOptions(input = {}) {
70
73
  }
71
74
  }
72
75
  const staleAge = input.olderThan ?? merged.staleAge;
76
+ const scanBudgetMs = toMs(merged.scanBudget);
77
+ if (!Number.isFinite(scanBudgetMs) || scanBudgetMs <= 0) {
78
+ throw new Error('scanBudget must be a positive finite duration');
79
+ }
73
80
  const roots = toList(merged.roots);
74
81
  return {
75
82
  ...merged,
@@ -83,6 +90,7 @@ export function resolveOptions(input = {}) {
83
90
  ? null
84
91
  : toMs(merged.investigationMaxAge),
85
92
  minSizeBytes: toBytes(merged.minSize),
93
+ scanBudgetMs,
86
94
  journalKeepBytes: toBytes(merged.journalKeep),
87
95
  containerFilter: toList(merged.containers),
88
96
  allowDirtyContainers: toList(merged.allowDirtyContainers),
package/src/report.js CHANGED
@@ -52,11 +52,11 @@ function itemLine(item, showEnv) {
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)'
55
+ ? ` (${formatBytes(item.totalBytes ?? 0)} visible; may be in an image layer; freeable bytes unknown, excluded from totals)`
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) {
@@ -102,7 +102,7 @@ function blockedSection(report, options) {
102
102
  }
103
103
  return [
104
104
  '',
105
- `BLOCKED ${plural(items.length, 'item')} (kept)`,
105
+ `BLOCKED ${plural(items.length, 'item')} (kept), ${formatBytes(report.totals.blocked.bytes)} known writable, ${formatBytes(report.totals.blocked.totalBytes ?? report.totals.blocked.bytes)} visible${report.totals.blocked.unknownBytes ? ` (${formatBytes(report.totals.blocked.unknownBytes)} with unknown writable size)` : ''}`,
106
106
  ...limited(lines, options.limit * 2),
107
107
  ];
108
108
  }
@@ -318,10 +318,32 @@ export function formatReport(report, options = {}) {
318
318
  `disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
319
319
  formatDisk(host.disk),
320
320
  ];
321
+ if (host.swap) {
322
+ lines.push(
323
+ `Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
324
+ );
325
+ lines.push(host.swap.attribution);
326
+ for (const holder of host.swap.holders) {
327
+ lines.push(
328
+ ` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
329
+ );
330
+ }
331
+ lines.push(host.swap.advice);
332
+ }
321
333
  for (const tier of TIERS) {
322
334
  lines.push(...tierSection(report, tier, { limit }));
323
335
  }
324
336
  for (const env of report.environments) {
337
+ if (env.layerSource) {
338
+ lines.push(` ${env.label}: layer source ${env.layerSource}`);
339
+ }
340
+ if (env.scanStatus === 'goal-reached') {
341
+ lines.push(
342
+ ` ${env.label}: scan stopped after the free-space goal was met`
343
+ );
344
+ } else if (env.scanStatus && env.scanStatus !== 'complete') {
345
+ lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
346
+ }
325
347
  if (env.hint) {
326
348
  lines.push(` ${env.label}: ${env.hint}`);
327
349
  }
@@ -0,0 +1,66 @@
1
+ /** Download leftovers of language version managers; installed runtimes are kept. */
2
+ export const TOOLCHAIN_CACHE_RULES = [
3
+ {
4
+ id: 'perlbrew-build',
5
+ ecosystem: 'scripting',
6
+ description: 'perlbrew build trees and source archives',
7
+ paths: [
8
+ '~/perl5/perlbrew/build',
9
+ '~/perl5/perlbrew/dists',
10
+ '~/.perl5/build',
11
+ '~/.perl5/dists',
12
+ ],
13
+ aggregate: true,
14
+ busy: ['perlbrew', 'perl', 'make'],
15
+ native: {
16
+ tool: 'perlbrew',
17
+ argv: ['perlbrew', 'clean'],
18
+ cacheRoot: '~/perl5/perlbrew',
19
+ },
20
+ },
21
+ {
22
+ id: 'sdkman-tmp',
23
+ ecosystem: 'jvm',
24
+ description: 'SDKMAN download leftovers',
25
+ paths: ['~/.sdkman/tmp/*'],
26
+ aggregate: true,
27
+ busy: ['sdk', 'sdkman', 'java', 'curl', 'unzip'],
28
+ native: {
29
+ tool: 'bash',
30
+ init: '~/.sdkman/bin/sdkman-init.sh',
31
+ command: 'sdk flush tmp',
32
+ },
33
+ },
34
+ {
35
+ id: 'sdkman-archives',
36
+ ecosystem: 'jvm',
37
+ description: 'SDKMAN downloaded archives',
38
+ paths: ['~/.sdkman/archives/*'],
39
+ aggregate: true,
40
+ busy: ['sdk', 'sdkman', 'java', 'curl', 'unzip'],
41
+ },
42
+ {
43
+ id: 'nvm-cache',
44
+ ecosystem: 'javascript',
45
+ description: 'nvm downloaded Node archives',
46
+ paths: ['~/.nvm/.cache'],
47
+ busy: ['nvm', 'node', 'curl'],
48
+ native: { tool: 'sh', init: '~/.nvm/nvm.sh', command: 'nvm cache clear' },
49
+ },
50
+ {
51
+ id: 'ruby-gem-cache',
52
+ ecosystem: 'ruby',
53
+ description: 'downloaded Ruby gem archives',
54
+ paths: [
55
+ '~/.rbenv/versions/*/lib/ruby/gems/*/cache/*.gem',
56
+ '~/.rvm/gems/*/cache/*.gem',
57
+ '~/.local/share/gem/ruby/*/cache/*.gem',
58
+ '~/.gem/ruby/*/cache/*.gem',
59
+ '{GEM_HOME}/cache/*.gem',
60
+ '{GEM_PATH}/cache/*.gem',
61
+ ],
62
+ aggregate: true,
63
+ fileOnly: true,
64
+ busy: ['gem', 'bundle', 'ruby'],
65
+ },
66
+ ];