disk-space-saviour 0.13.0 → 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.
@@ -18,6 +18,7 @@
18
18
  export const OTHER_RULES = [
19
19
  {
20
20
  id: 'playwright-browsers',
21
+ protectProfiles: true,
21
22
  ecosystem: 'browsers',
22
23
  description: 'old Playwright browser revisions',
23
24
  paths: [
@@ -27,7 +28,7 @@ export const OTHER_RULES = [
27
28
  ],
28
29
  versionPattern: /^(?<family>[a-z_]+)-(?<version>\d+)$/,
29
30
  minAge: 'inactive',
30
- tier: 'moderate',
31
+ tier: 'safe',
31
32
  busy: [
32
33
  'playwright',
33
34
  'playwright-mcp',
@@ -49,7 +50,7 @@ export const OTHER_RULES = [
49
50
  ],
50
51
  versionPattern: /^(?<version>\d+(?:\.\d+){1,3})$/,
51
52
  minAge: 'inactive',
52
- tier: 'moderate',
53
+ tier: 'safe',
53
54
  busy: ['playwright'],
54
55
  },
55
56
  {
@@ -59,7 +60,7 @@ export const OTHER_RULES = [
59
60
  paths: ['~/.cache/puppeteer/*/*'],
60
61
  versionPattern: /^(?<family>[a-z0-9_-]+)-(?<version>\d[\d.]*)$/,
61
62
  minAge: 'inactive',
62
- tier: 'moderate',
63
+ tier: 'safe',
63
64
  busy: ['puppeteer', 'chrome', 'chromium', 'headless_shell'],
64
65
  },
65
66
  {
@@ -69,7 +70,7 @@ export const OTHER_RULES = [
69
70
  paths: ['~/.cache/Cypress/*', '~/Library/Caches/Cypress/*'],
70
71
  versionPattern: /^(?<version>\d+(?:\.\d+){1,3})$/,
71
72
  minAge: 'inactive',
72
- tier: 'moderate',
73
+ tier: 'safe',
73
74
  busy: ['Cypress', 'cypress'],
74
75
  },
75
76
  {
@@ -79,7 +80,7 @@ export const OTHER_RULES = [
79
80
  paths: ['~/.cache/selenium/*/*/*'],
80
81
  versionPattern: /^(?<version>\d+(?:\.\d+){1,3})$/,
81
82
  minAge: 'inactive',
82
- tier: 'moderate',
83
+ tier: 'safe',
83
84
  busy: ['chromedriver', 'geckodriver'],
84
85
  },
85
86
  {
@@ -464,6 +465,29 @@ export const OTHER_RULES = [
464
465
  aggregate: true,
465
466
  paths: ['/var/lib/systemd/coredump/*', '/var/crash/*', '/cores/*'],
466
467
  },
468
+ {
469
+ id: 'start-command-isolation-logs',
470
+ ecosystem: 'agents',
471
+ scope: 'tmp',
472
+ description: 'old start-command Docker isolation logs',
473
+ tier: 'moderate',
474
+ paths: ['{TMP}/start-command/logs/isolation/docker/*.log'],
475
+ minAge: 'stale',
476
+ busy: [],
477
+ },
478
+ {
479
+ id: 'sanitized-upload-staging',
480
+ ecosystem: 'agents',
481
+ scope: 'tmp',
482
+ description: 'old sanitized log upload staging data',
483
+ tier: 'moderate',
484
+ paths: [
485
+ '{TMP}/log-tmp-hive-mind-log-upload-*-sanitized-*',
486
+ '{TMP}/log-home-box-hive-telegram-bot-*',
487
+ ],
488
+ minAge: 'stale',
489
+ busy: [],
490
+ },
467
491
  {
468
492
  id: 'gh-issue-solver-workdirs',
469
493
  ecosystem: 'tmp',
@@ -101,6 +101,29 @@ export function compareVersions(left, right) {
101
101
  }
102
102
 
103
103
  export const VERSION_RULES = [
104
+ {
105
+ id: 'claude-code-versions',
106
+ ecosystem: 'agents',
107
+ manager: 'Claude Code',
108
+ description: 'Claude Code binary',
109
+ dirs: ['~/.local/share/claude/versions/*'],
110
+ installTypes: ['file'],
111
+ defaultLinks: ['~/.local/bin/claude'],
112
+ busy: ['claude'],
113
+ recheck: true,
114
+ },
115
+ {
116
+ id: 'copilot-cli-versions',
117
+ ecosystem: 'agents',
118
+ manager: 'Copilot CLI',
119
+ description: 'Copilot CLI package',
120
+ dirs: ['~/.cache/copilot/pkg/*/*'],
121
+ defaultLinks: ['~/.local/bin/copilot'],
122
+ linkVersion: (target) => /\/pkg\/[^/]+\/([^/]+)/.exec(target ?? '')?.[1],
123
+ busy: ['copilot'],
124
+ recheck: true,
125
+ },
126
+
104
127
  {
105
128
  id: 'nvm-node',
106
129
  ecosystem: 'javascript',
package/src/scan.js CHANGED
@@ -3,14 +3,19 @@
3
3
  * Docker, inside running containers and nested daemons. Never deletes.
4
4
  */
5
5
 
6
+ import {
7
+ configureContainerHost,
8
+ resetWritableLayers,
9
+ } from './docker/writable.js';
6
10
  import { scanDocker } from './docker/scan.js';
7
11
  import { LocalEnv } from './env/local.js';
12
+ import { configureScanPolicy } from './env/policy.js';
8
13
  import { block, tierTotals } from './items.js';
9
14
  import { hostExecutor, trace } from './exec.js';
10
15
  import { GitInspector } from './git.js';
11
16
  import { LivenessProbe } from './liveness.js';
12
17
  import { resolveOptions } from './options.js';
13
- import { existingPaths, isWithin, matchesGlob } from './paths.js';
18
+ import { existingPaths, isWithin, matchesExcluded } from './paths.js';
14
19
  import { scanAgents } from './scanners/agents.js';
15
20
  import { scanGlobal } from './scanners/global.js';
16
21
  import { scanProjects } from './scanners/projects.js';
@@ -27,6 +32,8 @@ export const SCANNERS = {
27
32
 
28
33
  export const REPORT_SCHEMA = 1;
29
34
  const trustedReports = new WeakMap();
35
+ const reportEnvironments = new WeakMap();
36
+ export const scannedEnvironment = (report) => reportEnvironments.get(report);
30
37
  const RESCAN_OPTIONS = new Set([
31
38
  'roots',
32
39
  'scanners',
@@ -43,6 +50,7 @@ const RESCAN_OPTIONS = new Set([
43
50
  'exclude',
44
51
  'includeVolumes',
45
52
  'removeStoppedContainers',
53
+ 'investigationMaxAge',
46
54
  'removeContainers',
47
55
  'removeImages',
48
56
  'allowDirtyRepos',
@@ -115,6 +123,10 @@ async function defaultRoots(env, homes, tmpDirs) {
115
123
  * @param {{roots?: string[]|null}} [overrides]
116
124
  */
117
125
  export async function environmentContext(env, options, overrides = {}) {
126
+ await configureScanPolicy(env, {
127
+ ...options,
128
+ roots: overrides.roots ?? options.roots,
129
+ });
118
130
  const liveness = new LivenessProbe(env, {
119
131
  staleAgeMs: options.staleAgeMs,
120
132
  now: options.now,
@@ -164,16 +176,56 @@ export async function scanEnvironment(env, options, extra = {}) {
164
176
  }
165
177
  trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
166
178
  }
179
+ await attributeStorage(env, items);
180
+ for (const item of items) {
181
+ for (const target of item.paths) {
182
+ block(item, env.scanPolicy.removalReason(target));
183
+ }
184
+ }
185
+ for (const message of env.accessErrors?.values() ?? []) {
186
+ errors.push({ env: env.id, scanner: 'filesystem', message });
187
+ }
167
188
  return items;
168
189
  }
169
190
 
191
+ async function attributeStorage(env, items) {
192
+ if (env.writableLayer) {
193
+ const usages = await env.usageMany([
194
+ ...new Set(items.flatMap((item) => item.paths)),
195
+ ]);
196
+ for (const item of items.filter((entry) => entry.paths.length > 0)) {
197
+ const measured = item.paths
198
+ .map((target) => usages.get(target))
199
+ .filter(Boolean);
200
+ item.bytes = measured.reduce((sum, usage) => sum + usage.bytes, 0);
201
+ item.totalBytes = measured.reduce(
202
+ (sum, usage) => sum + (usage.totalBytes ?? usage.bytes),
203
+ 0
204
+ );
205
+ item.imageBytes = measured.reduce(
206
+ (sum, usage) => sum + (usage.imageBytes ?? 0),
207
+ 0
208
+ );
209
+ item.imageRef =
210
+ measured.find((usage) => usage.imageRef)?.imageRef ?? null;
211
+ item.sizeUnknown = measured.some((usage) => usage.sizeUnknown);
212
+ if (!item.sizeUnknown && item.bytes === 0 && item.imageBytes > 0) {
213
+ block(
214
+ item,
215
+ 'all bytes are in the image layer; remove the stopped container and its unused image after the task completes, or rebuild without caches'
216
+ );
217
+ }
218
+ }
219
+ }
220
+ }
221
+
170
222
  /**
171
223
  * Container ids of the container dss itself runs in (from mount sources
172
224
  * such as `/var/lib/docker/containers/<id>/hostname`).
173
225
  * @returns {Promise<string[]>}
174
226
  */
175
227
  export async function selfContainerIds(env) {
176
- if (env.platform !== 'linux') {
228
+ if (env.platform !== 'linux' || typeof env.readText !== 'function') {
177
229
  return [];
178
230
  }
179
231
  const ids = new Set();
@@ -210,13 +262,13 @@ function matchesOnly(item, only) {
210
262
  }
211
263
 
212
264
  function excluded(item, exclude, pathApi) {
213
- return item.paths.some((target) =>
214
- exclude.some(
215
- (pattern) =>
216
- isWithin(target, pattern, pathApi) ||
217
- isWithin(pattern, target, pathApi) ||
218
- matchesGlob(pathApi.basename(target), pattern)
219
- )
265
+ return item.paths.some(
266
+ (target) =>
267
+ matchesExcluded(target, exclude, pathApi) ||
268
+ exclude.some(
269
+ (pattern) =>
270
+ !/[*?[]/.test(pattern) && isWithin(pattern, target, pathApi)
271
+ )
220
272
  );
221
273
  }
222
274
 
@@ -237,7 +289,10 @@ function underDaemonRoot(item, daemons, pathApi) {
237
289
  export function filterItems(items, options, daemons = [], pathApi) {
238
290
  return items.filter(
239
291
  (item) =>
240
- item.bytes >= options.minSizeBytes &&
292
+ (item.bytes >= options.minSizeBytes ||
293
+ item.sizeUnknown ||
294
+ item.imageBytes >= options.minSizeBytes ||
295
+ item.container?.pinnedImage?.bytes >= options.minSizeBytes) &&
241
296
  matchesOnly(item, options.only) &&
242
297
  !excluded(item, options.exclude, pathApi) &&
243
298
  !underDaemonRoot(item, daemons, pathApi)
@@ -245,7 +300,14 @@ export function filterItems(items, options, daemons = [], pathApi) {
245
300
  }
246
301
 
247
302
  function envDescriptor(env, depth, chain) {
248
- return { id: env.id, label: env.label, kind: env.kind, depth, chain };
303
+ return {
304
+ id: env.id,
305
+ label: env.label,
306
+ kind: env.kind,
307
+ depth,
308
+ chain,
309
+ hint: env.probeHint ?? null,
310
+ };
249
311
  }
250
312
 
251
313
  async function withDisk(env, descriptor) {
@@ -269,6 +331,7 @@ async function dockerSection(env, options, errors) {
269
331
  { ...options, selfContainerIds: await selfContainerIds(env) },
270
332
  (inner) => scanEnvironment(inner, options, { errors })
271
333
  );
334
+ errors.push(...result.errors);
272
335
  if (options.docker === true && result.daemons.length === 0) {
273
336
  errors.push({
274
337
  env: env.id,
@@ -314,6 +377,15 @@ export async function scan(input = {}) {
314
377
  const env = input.env ?? new LocalEnv();
315
378
  const startedAt = options.now();
316
379
  const errors = [];
380
+ resetWritableLayers(env.executor);
381
+ env.writableLayer = null;
382
+ env.scanPolicy = null;
383
+ await configureScanPolicy(env, options);
384
+ await configureContainerHost(
385
+ env,
386
+ options.docker === false ? [] : await selfContainerIds(env),
387
+ options
388
+ );
317
389
  const hostItems =
318
390
  options.host === false
319
391
  ? []
@@ -331,7 +403,7 @@ export async function scan(input = {}) {
331
403
  environments.push(inner);
332
404
  }
333
405
  environments[0] = await withDisk(env, environments[0]);
334
- return buildReport({
406
+ const report = buildReport({
335
407
  options,
336
408
  env,
337
409
  items,
@@ -340,6 +412,8 @@ export async function scan(input = {}) {
340
412
  errors,
341
413
  startedAt,
342
414
  });
415
+ reportEnvironments.set(report, env);
416
+ return report;
343
417
  }
344
418
 
345
419
  function reportOptions(options) {
@@ -132,7 +132,7 @@ async function appItems(context, app, root) {
132
132
  ...verdict,
133
133
  checks: { busy: [], cwd: null, mtime: true },
134
134
  });
135
- const busy = scanTimeBusy(context, item);
135
+ const busy = await scanTimeBusy(context, item);
136
136
  if (busy) {
137
137
  block(item, `busy: ${busy}`);
138
138
  }
@@ -25,7 +25,8 @@ export async function listMany(env, dirs) {
25
25
  * Busy reason at scan time (recent writes, running tool, open files).
26
26
  * @returns {string|null}
27
27
  */
28
- export function scanTimeBusy(context, item) {
28
+ export async function scanTimeBusy(context, item) {
29
+ await context.liveness?.resolve(item.paths);
29
30
  return context.liveness ? context.liveness.busyReason(item) : null;
30
31
  }
31
32
 
@@ -6,12 +6,17 @@
6
6
 
7
7
  import { CACHE_RULES } from '../rules/ecosystems.js';
8
8
  import { OTHER_RULES } from '../rules/other.js';
9
+ import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
9
10
  import { compareVersions } from '../rules/versions.js';
10
11
  import { block, makeItem } from '../items.js';
11
12
  import { expandGlobPaths, expandRulePath } from '../paths.js';
12
13
  import { olderThan, scanTimeBusy } from './common.js';
13
14
 
14
- export const GLOBAL_RULES = [...CACHE_RULES, ...OTHER_RULES];
15
+ export const GLOBAL_RULES = [
16
+ ...CACHE_RULES,
17
+ ...expandChromiumCaches(OTHER_RULES),
18
+ ...APP_RULES,
19
+ ];
15
20
 
16
21
  function scopeBases(rule, homes, tmpDirs) {
17
22
  const scope = rule.scope ?? 'home';
@@ -123,6 +128,9 @@ async function olderVersions(env, rule, paths) {
123
128
  async function buildItem(context, rule, base, group) {
124
129
  const { env } = context;
125
130
  const paths = group.map((entry) => entry.path);
131
+ const appRoot = rule.markers
132
+ ? env.path.dirname(paths[0].replace(/[/\\]Service Worker[/\\].*$/, ''))
133
+ : null;
126
134
  const { action, blocker } = await nativeAction(context, rule, base, paths);
127
135
  const scope = rule.scope ?? 'home';
128
136
  const item = makeItem(env, {
@@ -138,11 +146,15 @@ async function buildItem(context, rule, base, group) {
138
146
  reason: rule.minAge
139
147
  ? `untouched for longer than the ${rule.minAge === 'stale' ? 'activity' : 'inactivity'} window`
140
148
  : 're-downloaded or re-created on demand',
149
+ recheck: rule.git === 'ifPresent' ? { type: 'scratch' } : undefined,
141
150
  action: action ?? { type: 'none' },
142
151
  checks: {
143
- busy: rule.busy ?? [],
152
+ busy: [
153
+ ...(rule.busy ?? []),
154
+ ...(appRoot ? [env.path.basename(appRoot)] : []),
155
+ ],
144
156
  cwd: scope === 'tmp' ? paths[0] : null,
145
- mtime: scope === 'tmp' || Boolean(rule.minAge),
157
+ mtime: rule.mtime ?? (scope === 'tmp' || Boolean(rule.minAge)),
146
158
  },
147
159
  });
148
160
  block(item, blocker);
@@ -157,12 +169,14 @@ async function addBlockers(context, rule, item) {
157
169
  }
158
170
  if (rule.git) {
159
171
  for (const target of item.paths) {
160
- for (const reason of await git.treeBlockers(target)) {
172
+ for (const reason of await git.treeBlockers(target, {
173
+ allowNoRepo: rule.git === 'ifPresent',
174
+ })) {
161
175
  block(item, reason);
162
176
  }
163
177
  }
164
178
  }
165
- const busy = scanTimeBusy(context, item);
179
+ const busy = await scanTimeBusy(context, item);
166
180
  if (busy) {
167
181
  block(item, `busy: ${busy}`);
168
182
  }
@@ -193,8 +207,16 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
193
207
  const candidates = [
194
208
  ...new Set(patterns.flatMap((pattern) => matches.get(pattern) ?? [])),
195
209
  ].filter((target) => !seen.has(target));
196
- candidates.forEach((target) => seen.add(target));
197
- const paths = await olderVersions(env, rule, candidates);
210
+
211
+ const accepted = [];
212
+ for (const target of candidates) {
213
+ if (!(await acceptedPath(env, rule, target))) {
214
+ continue;
215
+ }
216
+ accepted.push(target);
217
+ seen.add(target);
218
+ }
219
+ const paths = await olderVersions(env, rule, accepted);
198
220
  if (paths.length > 0) {
199
221
  found.push({ rule, base, paths });
200
222
  }
@@ -215,3 +237,33 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
215
237
  }
216
238
  return items;
217
239
  }
240
+
241
+ async function acceptedPath(env, rule, target) {
242
+ const name = env.path.basename(target);
243
+ if (rule.excludeNames?.includes(name)) {
244
+ return false;
245
+ }
246
+ if (rule.directoryOnly && (await env.stat(target))?.type !== 'dir') {
247
+ return false;
248
+ }
249
+ if (rule.fileOnly && (await env.stat(target))?.type !== 'file') {
250
+ return false;
251
+ }
252
+ if (
253
+ rule.protectProfiles &&
254
+ /(?:mcp|profile|userdata|user-data)/i.test(name)
255
+ ) {
256
+ return false;
257
+ }
258
+ if (!rule.markers) {
259
+ return true;
260
+ }
261
+ const parent = env.path.dirname(
262
+ target.replace(/[/\\]Service Worker[/\\].*$/, '')
263
+ );
264
+ return (
265
+ await Promise.all(
266
+ rule.markers.map((marker) => env.exists(env.path.join(parent, marker)))
267
+ )
268
+ ).some(Boolean);
269
+ }
@@ -169,7 +169,7 @@ function projectTier(context, rule, lastActivityMs) {
169
169
  const { inactiveMs } = context.options;
170
170
  if (olderThan(context, lastActivityMs, inactiveMs)) {
171
171
  return {
172
- tier: 'moderate',
172
+ tier: rule.id === 'node-modules' ? 'safe' : 'moderate',
173
173
  reason: 'project inactive, re-created by the build or install step',
174
174
  };
175
175
  }
@@ -185,7 +185,7 @@ async function addBlockers(context, item, requireClean) {
185
185
  })) {
186
186
  block(item, reason);
187
187
  }
188
- const busy = scanTimeBusy(context, item);
188
+ const busy = await scanTimeBusy(context, item);
189
189
  if (busy) {
190
190
  block(item, `busy: ${busy}`);
191
191
  }
@@ -209,7 +209,7 @@ async function projectActivity(context, project, candidate, usage, globs) {
209
209
  }
210
210
 
211
211
  async function projectItem(context, { candidate, rule }, usage, globs) {
212
- const { env, options } = context;
212
+ const { env } = context;
213
213
  const project = rule.parentName
214
214
  ? env.path.dirname(candidate.parent)
215
215
  : candidate.parent;
@@ -234,11 +234,7 @@ async function projectItem(context, { candidate, rule }, usage, globs) {
234
234
  ...projectTier(context, rule, lastActivityMs),
235
235
  checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
236
236
  });
237
- await addBlockers(
238
- context,
239
- item,
240
- item.tier !== 'safe' && !options.allowDirtyRepos
241
- );
237
+ await addBlockers(context, item, false);
242
238
  return item;
243
239
  }
244
240
 
@@ -55,8 +55,8 @@ export function parseBrewCleanup(output) {
55
55
  );
56
56
  }
57
57
 
58
- function withBusy(context, item) {
59
- const busy = scanTimeBusy(context, item);
58
+ async function withBusy(context, item) {
59
+ const busy = await scanTimeBusy(context, item);
60
60
  if (busy) {
61
61
  block(item, `busy: ${busy}`);
62
62
  }
@@ -126,7 +126,7 @@ async function snapItems(context, isRoot) {
126
126
  if (!isRoot) {
127
127
  block(item, 'system location, run as root to clean it');
128
128
  }
129
- items.push(withBusy(context, item));
129
+ items.push(await withBusy(context, item));
130
130
  }
131
131
  return items;
132
132
  }
@@ -154,7 +154,7 @@ async function brewItems(context, isRoot) {
154
154
  action: { type: 'command', argv: ['brew', 'cleanup'], measure: [] },
155
155
  checks: { busy: ['brew'], cwd: null, mtime: false },
156
156
  });
157
- return [withBusy(context, item)];
157
+ return [await withBusy(context, item)];
158
158
  }
159
159
 
160
160
  /**
@@ -26,7 +26,11 @@ async function installDirs(env, rule, home) {
26
26
  if ((rule.exclude ?? []).includes(name)) {
27
27
  continue;
28
28
  }
29
- if ((await env.stat(candidate))?.type !== 'dir') {
29
+ if (
30
+ !(rule.installTypes ?? ['dir']).includes(
31
+ (await env.stat(candidate))?.type
32
+ )
33
+ ) {
30
34
  continue;
31
35
  }
32
36
  if (
@@ -86,7 +90,11 @@ async function managerRefs(env, rule, home, installs) {
86
90
  refs.push(...(await aliasRefs(env, expand(rule.aliasDir))));
87
91
  }
88
92
  for (const link of rule.defaultLinks ?? []) {
89
- refs.push(...linkRefs(await env.readLink(expand(link))));
93
+ const target = await env.readLink(expand(link));
94
+ refs.push(...linkRefs(target));
95
+ if (rule.linkVersion) {
96
+ refs.push(rule.linkVersion(target));
97
+ }
90
98
  }
91
99
  if (rule.currentLink) {
92
100
  const parents = new Set(installs.map((dir) => env.path.dirname(dir)));
@@ -195,10 +203,14 @@ async function ruleItems(context, rule, home, pins) {
195
203
  tier: 'moderate',
196
204
  reason: `not the ${rule.manager} default, not pinned by a scanned project, not the newest`,
197
205
  checks: { busy: rule.busy ?? [], cwd: dir, mtime: true },
198
- recheck: rule.id === 'pyenv-python' ? { type: 'pyenv-envs' } : null,
206
+ recheck: rule.recheck
207
+ ? { type: 'version', home }
208
+ : rule.id === 'pyenv-python'
209
+ ? { type: 'pyenv-envs' }
210
+ : null,
199
211
  });
200
212
  await blockPyenvEnvironments(env, rule, dir, item);
201
- const busy = scanTimeBusy(context, item);
213
+ const busy = await scanTimeBusy(context, item);
202
214
  if (busy) {
203
215
  block(item, `busy: ${busy}`);
204
216
  }