disk-space-saviour 0.19.0 → 0.21.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/src/liveness.js CHANGED
@@ -212,6 +212,7 @@ export class LivenessProbe {
212
212
  this.realPaths = new Map();
213
213
  this.pathOwners = new Map();
214
214
  this.pathStats = new Map();
215
+ this.inodes = new Map();
215
216
  }
216
217
 
217
218
  async refresh(force = false) {
@@ -230,6 +231,50 @@ export class LivenessProbe {
230
231
  this.realPaths.clear();
231
232
  this.pathStats.clear();
232
233
  this.pathOwners.clear();
234
+ this.inodes.clear();
235
+ }
236
+
237
+ /**
238
+ * Inodes of `executables` and of the open files that share their names,
239
+ * because a process running a hard link of an executable reports
240
+ * whichever path it was started from.
241
+ * @param {string[]} [executables]
242
+ */
243
+ async resolveExecutables(executables = []) {
244
+ if (executables.length === 0) {
245
+ return;
246
+ }
247
+ const names = new Set(
248
+ executables.map((target) => this.env.path.basename(target))
249
+ );
250
+ const missing = [
251
+ ...executables,
252
+ ...[...(this.openPaths ?? [])].filter((open) =>
253
+ names.has(this.env.path.basename(open))
254
+ ),
255
+ ].filter((target) => !this.inodes.has(target));
256
+ const stats = await Promise.all(
257
+ missing.map((target) => this.env.stat(target))
258
+ );
259
+ missing.forEach((target, index) =>
260
+ this.inodes.set(target, stats[index]?.inode ?? null)
261
+ );
262
+ }
263
+
264
+ /** A process running one of `executables` under any of its links. */
265
+ executableUsage(executables = []) {
266
+ for (const target of executables) {
267
+ const inode = this.inodes.get(target);
268
+ if (!inode) {
269
+ continue;
270
+ }
271
+ for (const open of this.openPaths ?? []) {
272
+ if (open !== target && this.inodes.get(open) === inode) {
273
+ return `in use: ${open} is ${target}`;
274
+ }
275
+ }
276
+ }
277
+ return null;
233
278
  }
234
279
 
235
280
  /**
@@ -511,6 +556,7 @@ export class LivenessProbe {
511
556
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
512
557
  this.runningTool(checks.busy, checks.cwd, item) ??
513
558
  this.processUsage(paths) ??
559
+ this.executableUsage(checks.executables) ??
514
560
  this.uncertainUsage(item.paths ?? [])
515
561
  );
516
562
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Browser revisions that installed Playwright packages still launch.
3
+ *
4
+ * Playwright records every package it installed browsers for as a file in
5
+ * `<registry>/.links/` holding that package's directory, and the package's
6
+ * `browsers.json` names the revisions it launches. Playwright's own cleanup
7
+ * keeps a revision while a linked package that still exists names it; dss
8
+ * keeps the same revisions, so deleting one never breaks an installed
9
+ * project until its next `playwright install`.
10
+ */
11
+
12
+ /** Directory of a registry revision: `chromium-headless-shell` → `chromium_headless_shell-1248`. */
13
+ export function browserDirName(name, revision) {
14
+ return `${name.replace(/-/g, '_')}-${revision}`;
15
+ }
16
+
17
+ /** Revision directories one `browsers.json` names, platform overrides included. */
18
+ export function requiredDirs(text) {
19
+ const browsers = JSON.parse(text)?.browsers;
20
+ if (!Array.isArray(browsers)) {
21
+ throw new Error('no browsers list');
22
+ }
23
+ return browsers.flatMap((browser) =>
24
+ [browser.revision, ...Object.values(browser.revisionOverrides ?? {})]
25
+ .filter(Boolean)
26
+ .map((revision) => browserDirName(browser.name, revision))
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Maps each revision directory name to the linked packages that need it;
32
+ * `unreadable` lists links whose package exists but cannot be read.
33
+ */
34
+ export async function playwrightUsers(env, registry) {
35
+ const users = new Map();
36
+ const unreadable = [];
37
+ for (const link of await env.list(env.path.join(registry, '.links'))) {
38
+ const pkg = (await env.readText(link.path))?.trim();
39
+ if (!pkg) {
40
+ unreadable.push(link.path);
41
+ continue;
42
+ }
43
+ const manifest = env.path.join(pkg, 'browsers.json');
44
+ if (!(await env.exists(manifest))) {
45
+ // The project or its node_modules is gone; Playwright drops the link too.
46
+ continue;
47
+ }
48
+ try {
49
+ for (const dir of requiredDirs((await env.readText(manifest)) ?? '')) {
50
+ users.set(dir, [...(users.get(dir) ?? []), pkg]);
51
+ }
52
+ } catch {
53
+ unreadable.push(manifest);
54
+ }
55
+ }
56
+ return { users, unreadable };
57
+ }
58
+
59
+ /**
60
+ * Why `target`, a revision directory in a Playwright registry, must stay;
61
+ * null when no installed package needs it.
62
+ * @param {Map<string, Promise<object>>} [cache] registry → playwrightUsers
63
+ */
64
+ export async function playwrightBlocker(env, target, cache = new Map()) {
65
+ const registry = env.path.dirname(target);
66
+ if (!cache.has(registry)) {
67
+ cache.set(registry, playwrightUsers(env, registry));
68
+ }
69
+ const { users, unreadable } = await cache.get(registry);
70
+ if (unreadable.length > 0) {
71
+ return `cannot read Playwright install record ${unreadable[0]}`;
72
+ }
73
+ const needed = users.get(env.path.basename(target));
74
+ return needed
75
+ ? `Playwright in ${needed[0]} still uses ${env.path.basename(target)}`
76
+ : null;
77
+ }
package/src/report.js CHANGED
@@ -82,7 +82,14 @@ function tierSection(report, tier, options) {
82
82
  '',
83
83
  `${tier.toUpperCase()} ${plural(items.length, 'item')}, ${formatBytes(bytes)}`,
84
84
  ...limited(
85
- items.map((item) => itemLine(item, showEnv)),
85
+ items.flatMap((item) => [
86
+ itemLine(item, showEnv),
87
+ ...(item.globalPackages?.length
88
+ ? [
89
+ ` also removes global packages: ${item.globalPackages.join(', ')}`,
90
+ ]
91
+ : []),
92
+ ]),
86
93
  options.limit
87
94
  ),
88
95
  ];
@@ -351,6 +358,23 @@ function timingLines(report, { verbose }) {
351
358
  });
352
359
  }
353
360
 
361
+ function holderLine(holder) {
362
+ const details = [`${formatBytes(holder.memoryBytes)} memory`];
363
+ if (holder.compressedBytes !== undefined) {
364
+ details.push(`${formatBytes(holder.compressedBytes)} compressed`);
365
+ }
366
+ if (holder.swappedBytes !== undefined) {
367
+ details.push(`${formatBytes(holder.swappedBytes)} swapped`);
368
+ }
369
+ const change = holder.growth?.memoryBytes;
370
+ if (change) {
371
+ details.push(
372
+ `${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
373
+ );
374
+ }
375
+ return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
376
+ }
377
+
354
378
  /**
355
379
  * Renders a scan report.
356
380
  * @param {object} report
@@ -368,11 +392,12 @@ export function formatReport(report, options = {}) {
368
392
  lines.push(
369
393
  `Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
370
394
  );
395
+ for (const warning of host.swap.warnings ?? []) {
396
+ lines.push(`WARNING: ${warning}`);
397
+ }
371
398
  lines.push(host.swap.attribution);
372
399
  for (const holder of host.swap.holders) {
373
- lines.push(
374
- ` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
375
- );
400
+ lines.push(` ${holderLine(holder)}`);
376
401
  }
377
402
  lines.push(host.swap.advice);
378
403
  }
package/src/rules/apps.js CHANGED
@@ -36,13 +36,14 @@ export const APP_RULES = [
36
36
  },
37
37
  {
38
38
  id: 'telegram-downloaded-media',
39
- excludeNames: ['cache', 'cache-storage', 'short-cache'],
40
39
  ecosystem: 'apps',
41
40
  tier: 'moderate',
42
41
  description:
43
42
  'Telegram downloaded media: secret-chat media may not be recoverable',
43
+ // Only media Telegram downloads again from its cloud; `storage` holds
44
+ // the media database and `local-file-*` files the user sent.
44
45
  paths: [
45
- `${telegramAccounts}/*`,
46
+ `${telegramAccounts}/telegram-cloud-*`,
46
47
  ...telegramDesktop.map((root) => `${root}/user_data/downloads`),
47
48
  ],
48
49
  busy: ['Telegram', 'telegram-desktop'],
@@ -20,10 +20,12 @@ export const DOWNLOAD_RULES = [
20
20
  description:
21
21
  'Chromium app bundle clones left by exited browsers (an update makes them full copies)',
22
22
  // Chrome clones its bundle into the per-user `X` directory next to
23
- // `$TMPDIR` at launch and removes the clone when it exits. A running
24
- // browser maps the executables of its clone, so the open-file check
25
- // keeps every clone in use: a browser name would block orphans forever.
23
+ // `$TMPDIR` at launch and removes the clone when it exits. A clone
24
+ // stays while any process runs its hard-linked main executable (see
25
+ // ../scanners/app-clones.js): a browser name would block orphans
26
+ // forever, and open files miss a browser started before the update.
26
27
  paths: ['{TMP}/../X/*.code_sign_clone/code_sign_clone.*'],
28
+ appClone: { installed: ['/Applications', '~/Applications'] },
27
29
  directoryOnly: true,
28
30
  minAge: 'stale',
29
31
  busy: [],
@@ -31,13 +33,23 @@ export const DOWNLOAD_RULES = [
31
33
  {
32
34
  id: 'chrome-on-device-models',
33
35
  ecosystem: 'browsers',
34
- description: 'superseded Chrome on-device AI model versions',
36
+ description:
37
+ 'Chromium on-device AI models, downloaded again when a page uses built-in AI',
38
+ // Every installed version matches: the component updater fetches the
39
+ // model again on demand. A loaded model is held open and a download in
40
+ // progress writes to its directory, which the open-file check and the
41
+ // activity window catch.
35
42
  paths: [
36
- '~/Library/Application Support/Google/Chrome/OptGuideOnDeviceModel/*',
37
- '~/.config/google-chrome/OptGuideOnDeviceModel/*',
38
- '{LOCALAPPDATA}/Google/Chrome/User Data/OptGuideOnDeviceModel/*',
39
- ],
40
- versionPattern: /^(?<version>\d+(?:\.\d+){1,3})$/,
43
+ '~/Library/Application Support/Google/Chrome*',
44
+ '~/Library/Application Support/Chromium',
45
+ '~/Library/Application Support/Microsoft Edge*',
46
+ '~/.config/google-chrome*',
47
+ '~/.config/chromium',
48
+ '{LOCALAPPDATA}/Google/Chrome*/User Data',
49
+ '{LOCALAPPDATA}/Chromium/User Data',
50
+ '{LOCALAPPDATA}/Microsoft/Edge*/User Data',
51
+ ].map((root) => `${root}/OptGuideOnDevice*Model/*`),
52
+ directoryOnly: true,
41
53
  minAge: 'stale',
42
54
  busy: [],
43
55
  },
@@ -12,7 +12,11 @@
12
12
  * otherwise each match is its own item. `minAge: 'stale'` only reports
13
13
  * matches untouched for the stale-age window, `minAge: 'inactive'` for the
14
14
  * project inactivity window. `git: true` refuses matches that are Git work
15
- * trees with uncommitted, unpushed, or stashed changes.
15
+ * trees with uncommitted, unpushed, or stashed changes. `git: 'clone'`
16
+ * matches only Git work trees with an https or ssh remote, and refuses them
17
+ * unless every worktree is clean and the remote still advertises every
18
+ * local commit. `registry: 'playwright'` keeps revisions that a Playwright
19
+ * package recorded in the registry's `.links` still launches.
16
20
  */
17
21
 
18
22
  export const OTHER_RULES = [
@@ -29,15 +33,10 @@ export const OTHER_RULES = [
29
33
  versionPattern: /^(?<family>[a-z_]+)-(?<version>\d+)$/,
30
34
  minAge: 'inactive',
31
35
  tier: 'safe',
32
- busy: [
33
- 'playwright',
34
- 'playwright-mcp',
35
- 'chrome',
36
- 'chromium',
37
- 'headless_shell',
38
- 'firefox',
39
- 'webkit',
40
- ],
36
+ registry: 'playwright',
37
+ // A running Playwright launches the revisions its package names, which
38
+ // the registry check keeps; a revision a browser runs from is open.
39
+ busy: [],
41
40
  },
42
41
  {
43
42
  id: 'playwright-go-drivers',
@@ -519,6 +518,23 @@ export const OTHER_RULES = [
519
518
  minAge: 'stale',
520
519
  busy: ['chrome', 'chromium', 'headless_shell'],
521
520
  },
521
+ {
522
+ id: 'tmp-playwright-browsers',
523
+ ecosystem: 'tmp',
524
+ scope: 'tmp',
525
+ description:
526
+ 'Playwright browser installs and downloads in temporary directories',
527
+ paths: [
528
+ '{TMP}/playwright-download-*',
529
+ '{TMP}/ms-playwright',
530
+ '{TMP}/ms-playwright-*',
531
+ '{TMP}/*/ms-playwright',
532
+ '{TMP}/*/.cache/ms-playwright',
533
+ '{TMP}/*/Library/Caches/ms-playwright',
534
+ ],
535
+ minAge: 'stale',
536
+ busy: ['playwright', 'node', 'chrome', 'chromium', 'headless_shell'],
537
+ },
522
538
  {
523
539
  id: 'node-tmp-caches',
524
540
  ecosystem: 'tmp',
@@ -533,3 +549,33 @@ export const OTHER_RULES = [
533
549
  busy: ['node'],
534
550
  },
535
551
  ];
552
+
553
+ /**
554
+ * Checkouts cloned into temporary directories by tools, tests and agents,
555
+ * checked after every named rule so a more specific rule claims its own
556
+ * paths first.
557
+ */
558
+ export const TEMP_CLONE_RULES = [
559
+ {
560
+ id: 'temp-git-clones',
561
+ ecosystem: 'tmp',
562
+ scope: 'tmp',
563
+ description:
564
+ 'Git clones in temporary directories whose commits all exist on a remote',
565
+ tier: 'safe',
566
+ paths: ['{TMP}/*', '{TMP}/*/*'],
567
+ directoryOnly: true,
568
+ minAge: 'stale',
569
+ git: 'clone',
570
+ busy: [
571
+ 'git',
572
+ 'claude',
573
+ 'codex',
574
+ 'opencode',
575
+ 'agent',
576
+ 'node',
577
+ 'bun',
578
+ 'cargo',
579
+ ],
580
+ },
581
+ ];
@@ -10,6 +10,9 @@
10
10
  * matches (nvm runs the newest 20.x for `20`); otherwise it keeps them all.
11
11
  * `globalPackages` blocks versions holding packages the user installed
12
12
  * globally, which reinstalling the version would not bring back.
13
+ * `locks` names the lock file a manager writes for each running process of
14
+ * a version (`{version}` is replaced); a lock naming a live process, or one
15
+ * that cannot be read, keeps the version.
13
16
  *
14
17
  * `versionOf(dirPath)` maps an install directory to the version string that
15
18
  * defaults and pin files are compared with (see `versionMatches`).
@@ -114,6 +117,7 @@ export const VERSION_RULES = [
114
117
  dirs: ['~/.local/share/claude/versions/*'],
115
118
  installTypes: ['file'],
116
119
  defaultLinks: ['~/.local/bin/claude'],
120
+ locks: '~/.local/state/claude/locks/{version}.lock',
117
121
  busy: ['claude'],
118
122
  recheck: true,
119
123
  },
package/src/scan.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  configureContainerHost,
8
8
  resetWritableLayers,
9
9
  } from './docker/writable.js';
10
+ import path from 'node:path';
10
11
  import { setInterval, clearInterval } from 'node:timers';
11
12
  import { scanDocker } from './docker/scan.js';
12
13
  import { LocalEnv } from './env/local.js';
@@ -20,10 +21,11 @@ import { LivenessProbe } from './liveness.js';
20
21
  import { resolveOptions } from './options.js';
21
22
  import { existingPaths, isWithin, matchesExcluded } from './paths.js';
22
23
  import { scanAgents } from './scanners/agents.js';
23
- import { scanGlobal } from './scanners/global.js';
24
+ import { GLOBAL_RULES, scanGlobal, targetedRules } from './scanners/global.js';
24
25
  import { scanProjects } from './scanners/projects.js';
25
26
  import { scanSystem } from './scanners/system.js';
26
27
  import { scanVersions } from './scanners/versions.js';
28
+ import { VERSION_RULES } from './rules/versions.js';
27
29
  import { readSwap } from './swap.js';
28
30
  import {
29
31
  authenticateReport,
@@ -194,6 +196,17 @@ export async function environmentContext(env, options, overrides = {}) {
194
196
  * @param {{roots?: string[]|null, errors?: object[]}} [extra]
195
197
  * @returns {Promise<object[]>} items
196
198
  */
199
+ /** A run limited to global or version rules skips the other scanners. */
200
+ function selectedScanners(options) {
201
+ if (!targetedRules(options.only)) {
202
+ return options.scanners;
203
+ }
204
+ const rules = { global: GLOBAL_RULES, versions: VERSION_RULES };
205
+ return options.scanners.filter(
206
+ (name) => rules[name] && targetedRules(options.only, rules[name]).length > 0
207
+ );
208
+ }
209
+
197
210
  export async function scanEnvironment(env, options, extra = {}) {
198
211
  const controller = new AbortController();
199
212
  const abort = () => controller.abort(options.signal.reason);
@@ -321,7 +334,7 @@ async function scanEnvironmentScoped(
321
334
  };
322
335
  context.ready = ready;
323
336
  let incomplete = false;
324
- for (const name of options.scanners) {
337
+ for (const name of selectedScanners(options)) {
325
338
  checkSignal(env.signal);
326
339
  context.scanner = name;
327
340
  stage(name);
@@ -434,12 +447,33 @@ export async function selfContainerIds(env) {
434
447
  return [...ids];
435
448
  }
436
449
 
437
- function matchesOnly(item, only) {
450
+ /**
451
+ * `only` entries name an ecosystem, rule, kind or item id, or a path or
452
+ * glob: an item matches it when each of its paths lies inside the path, or
453
+ * the path or one of its ancestors matches the glob.
454
+ */
455
+ function matchesOnly(item, only, pathApi = path) {
438
456
  return (
439
457
  only.length === 0 ||
440
458
  only.some(
441
459
  (entry) =>
442
- entry === item.ecosystem || entry === item.rule || entry === item.kind
460
+ [item.ecosystem, item.rule, item.kind, item.id].includes(entry) ||
461
+ insideOnlyPath(item, entry, pathApi)
462
+ )
463
+ );
464
+ }
465
+
466
+ function insideOnlyPath(item, entry, pathApi) {
467
+ const glob = /[*?[]/.test(entry);
468
+ if (!glob && !pathApi.isAbsolute(entry) && !path.win32.isAbsolute(entry)) {
469
+ return false;
470
+ }
471
+ return (
472
+ item.paths?.length > 0 &&
473
+ item.paths.every((target) =>
474
+ glob
475
+ ? matchesExcluded(target, [entry], pathApi)
476
+ : isWithin(target, entry, pathApi)
443
477
  )
444
478
  );
445
479
  }
@@ -476,7 +510,7 @@ export function filterItems(items, options, daemons = [], pathApi) {
476
510
  item.sizeUnknown ||
477
511
  item.imageBytes >= options.minSizeBytes ||
478
512
  item.container?.pinnedImage?.bytes >= options.minSizeBytes) &&
479
- matchesOnly(item, options.only) &&
513
+ matchesOnly(item, options.only, pathApi) &&
480
514
  !excluded(item, options.exclude, pathApi) &&
481
515
  !underDaemonRoot(item, daemons, pathApi)
482
516
  );
@@ -496,13 +530,17 @@ function envDescriptor(env, depth, chain) {
496
530
  };
497
531
  }
498
532
 
499
- async function withDisk(env, descriptor) {
533
+ async function withDisk(env, descriptor, options) {
500
534
  const target = env.platform === 'win32' ? env.currentHome : '/';
501
535
  const disk = await env.diskUsage(target).catch(() => null);
502
536
  return {
503
537
  ...descriptor,
504
538
  disk: disk ? { path: target, ...disk } : null,
505
- swap: await readSwap(env),
539
+ swap: await readSwap(env, {
540
+ stateDir: options.cacheDir,
541
+ disk,
542
+ now: options.now?.(),
543
+ }),
506
544
  };
507
545
  }
508
546
 
@@ -632,7 +670,7 @@ async function scanScoped(input) {
632
670
  environments.push(inner);
633
671
  }
634
672
  environments[0] = await timed(env.timings, 'disk', () =>
635
- withDisk(env, environments[0])
673
+ withDisk(env, environments[0], options)
636
674
  );
637
675
  environments[0].timings = env.timings.toJSON();
638
676
  const report = buildReport({
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Chromium browsers on macOS clone their app bundle at launch with APFS
3
+ * `clonefile` and replace the clone's main executable with a hard link to
4
+ * the installed one (chrome/browser/mac/code_sign_clone_manager.mm). The
5
+ * browser holds no file open inside its clone: it runs the shared inode,
6
+ * which `lsof` reports under the installed path until an update replaces
7
+ * the app. After an update the clone keeps the running browser's code
8
+ * signature valid, so a clone stays while any process runs its executable.
9
+ * Until then the clone shares every block with the installed app, and
10
+ * removing it frees nothing but directory entries.
11
+ */
12
+
13
+ const BUNDLE_SUFFIX = '.bundle';
14
+
15
+ /**
16
+ * Main executables of the bundle cloned in `clone`, and whether the clone
17
+ * still shares its blocks with an app installed in one of `appDirs`.
18
+ * @returns {Promise<{executables: string[], sharedWith: string|null}>}
19
+ */
20
+ export async function appCloneDetails(env, clone, appDirs) {
21
+ const executables = [];
22
+ let sharedWith = null;
23
+ for (const bundle of await env.list(clone)) {
24
+ if (
25
+ bundle.type !== 'dir' ||
26
+ !bundle.name.endsWith(`.app${BUNDLE_SUFFIX}`)
27
+ ) {
28
+ continue;
29
+ }
30
+ const appName = bundle.name.slice(0, -BUNDLE_SUFFIX.length);
31
+ const macos = env.path.join(bundle.path, 'Contents', 'MacOS');
32
+ for (const entry of await env.list(macos)) {
33
+ if (entry.type !== 'file') {
34
+ continue;
35
+ }
36
+ executables.push(entry.path);
37
+ sharedWith ??= await installedCopy(env, entry, appName, appDirs);
38
+ }
39
+ }
40
+ return { executables, sharedWith };
41
+ }
42
+
43
+ async function installedCopy(env, executable, appName, appDirs) {
44
+ const inode = (await env.stat(executable.path))?.inode;
45
+ for (const dir of appDirs) {
46
+ const app = env.path.join(dir, appName);
47
+ const installed = env.path.join(app, 'Contents', 'MacOS', executable.name);
48
+ if (inode && (await env.stat(installed))?.inode === inode) {
49
+ return app;
50
+ }
51
+ }
52
+ return null;
53
+ }
@@ -27,6 +27,7 @@ export async function listMany(env, dirs) {
27
27
  */
28
28
  export async function scanTimeBusy(context, item) {
29
29
  await resolvePaths(context, item.paths);
30
+ await context.liveness?.resolveExecutables(item.checks?.executables);
30
31
  return context.liveness ? context.liveness.busyReason(item) : null;
31
32
  }
32
33