disk-space-saviour 0.19.0 → 0.20.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Clean every installed Chromium on-device AI model, including the current
8
+ version and the classifier model, in Chrome, Chromium and Edge. The browser
9
+ downloads a model again when a page uses its built-in AI; a loaded model or a
10
+ download in progress keeps it in place.
11
+
12
+ Keep a Claude Code version while its lock file names a running session or
13
+ cannot be read, and recheck locks, globally installed packages and other
14
+ version blockers right before deleting a toolchain version.
15
+
16
+ `--only` also targets a single item by its id and the items inside a path or
17
+ matching a glob. A run limited to global cache rules or their items scans only
18
+ those rules and skips project discovery.
19
+
20
+ A run limited to toolchain version rules, such as `--only claude-code-versions`,
21
+ scans only those rules and skips project discovery.
22
+
3
23
  ## 0.19.0
4
24
 
5
25
  ### Minor Changes
package/README.md CHANGED
@@ -68,8 +68,13 @@ dss docker scan|clean [--container ID] [--recursive]
68
68
 
69
69
  Run `dss --help` for every option. The most useful ones:
70
70
 
71
- - `--only NAME` limits a run to an ecosystem, rule or kind (`--only rust`,
72
- `--only npm-cache`, `--only docker-stopped-container`).
71
+ - `--only NAME|ID|PATH` limits a run to an ecosystem, rule or kind
72
+ (`--only rust`, `--only npm-cache`, `--only docker-stopped-container`), a
73
+ single item by its JSON `id`, or the items inside a path or matching a glob
74
+ (`--only ~/Library/Caches/Codex`, `--only '**/Crashpad/*'`). A path never
75
+ widens to the item holding it. When every entry names a cache or
76
+ toolchain version rule or one of its items, only those rules are scanned,
77
+ so `dss clean --only openai-app-caches --yes` takes seconds.
73
78
  - `--exclude PATH|GLOB` prunes matching paths and descendants before reading
74
79
  them. Patterns containing separators match full paths and their ancestors;
75
80
  other patterns also match ancestor basenames.
@@ -110,8 +115,8 @@ LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
110
115
  Windsurf, Qoder), AI agent caches and logs, the Discord cache, macOS app
111
116
  updates staged by Sparkle and Squirrel, package manager archives, trash, crash
112
117
  reports, core dumps and journald logs. Application downloads are covered too:
113
- Chrome bundle clones left by exited browsers, superseded Chrome on-device
114
- models, Google Updater installers, ChatGPT Atlas and Codex app caches,
118
+ Chrome bundle clones left by exited browsers, Chromium on-device AI models
119
+ (downloaded again when a page uses built-in AI), Google Updater installers, ChatGPT Atlas and Codex app caches,
115
120
  Electron and electron-builder archives, the Bun transpiler cache, Docker
116
121
  Desktop and Warp staged updates (kept while the app runs), Zed language
117
122
  servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
@@ -145,7 +150,10 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
145
150
  version holding globally installed npm packages or pyenv virtual
146
151
  environments is blocked; bundled `npm`/`corepack` and use-m caches
147
152
  (`<name>-v-<version>`) are reinstalled on demand and do not count.
148
- Container paths are checked individually before each bounded deletion batch.
153
+ A Claude Code version stays while a running session uses it: its
154
+ executable is open, or its `~/.local/state/claude/locks/<version>.lock`
155
+ names a live process or cannot be read. All of this is checked again right
156
+ before each version is deleted. Container paths are checked individually before each bounded deletion batch.
149
157
  - **Rust builds are pruned in two parts.** `cargo-superseded` removes
150
158
  older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
151
159
  `build/*`, `.fingerprint`) that a newer build replaced, but never while
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/clean.js CHANGED
@@ -403,9 +403,16 @@ export class Cleaner {
403
403
  },
404
404
  { rules: [rule] }
405
405
  );
406
- if (!candidates.some((candidate) => candidate.path === item.path)) {
406
+ const current = candidates.find(
407
+ (candidate) => candidate.path === item.path
408
+ );
409
+ if (!current) {
407
410
  return `${item.path} is now current, pinned, or the newest installed version`;
408
411
  }
412
+ // Locks of running processes and data reinstalling would not restore.
413
+ if (current.blockers.length > 0) {
414
+ return current.blockers[0];
415
+ }
409
416
  }
410
417
  if (
411
418
  item.recheck?.type === 'pyenv-envs' &&
package/src/cli.js CHANGED
@@ -40,7 +40,8 @@ Scope:
40
40
  --container ID|NAME limit Docker work to these containers (repeat)
41
41
  --max-depth N directory depth for project search (default 6)
42
42
  --scan-budget AGE time budget per filesystem environment (2m)
43
- --only NAME only these ecosystems, rules or kinds (repeat)
43
+ --only NAME|ID|PATH only these ecosystems, rules, kinds, item ids,
44
+ or items inside a path or glob (repeat)
44
45
  --exclude PATH|GLOB never touch matching paths (repeat)
45
46
  --scanner NAME projects, global, versions, agents, system
46
47
  --min-size SIZE ignore smaller items (default 1M)
@@ -31,13 +31,23 @@ export const DOWNLOAD_RULES = [
31
31
  {
32
32
  id: 'chrome-on-device-models',
33
33
  ecosystem: 'browsers',
34
- description: 'superseded Chrome on-device AI model versions',
34
+ description:
35
+ 'Chromium on-device AI models, downloaded again when a page uses built-in AI',
36
+ // Every installed version matches: the component updater fetches the
37
+ // model again on demand. A loaded model is held open and a download in
38
+ // progress writes to its directory, which the open-file check and the
39
+ // activity window catch.
35
40
  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})$/,
41
+ '~/Library/Application Support/Google/Chrome*',
42
+ '~/Library/Application Support/Chromium',
43
+ '~/Library/Application Support/Microsoft Edge*',
44
+ '~/.config/google-chrome*',
45
+ '~/.config/chromium',
46
+ '{LOCALAPPDATA}/Google/Chrome*/User Data',
47
+ '{LOCALAPPDATA}/Chromium/User Data',
48
+ '{LOCALAPPDATA}/Microsoft/Edge*/User Data',
49
+ ].map((root) => `${root}/OptGuideOnDevice*Model/*`),
50
+ directoryOnly: true,
41
51
  minAge: 'stale',
42
52
  busy: [],
43
53
  },
@@ -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
  );
@@ -9,7 +9,7 @@ import { scanItems } from '../env/scope.js';
9
9
  import { CACHE_RULES } from '../rules/ecosystems.js';
10
10
  import { OTHER_RULES } from '../rules/other.js';
11
11
  import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
12
- import { compareVersions } from '../rules/versions.js';
12
+ import { VERSION_RULES, compareVersions } from '../rules/versions.js';
13
13
  import { block, makeItem } from '../items.js';
14
14
  import { expandGlobPaths, expandRulePath, isWithin } from '../paths.js';
15
15
  import { olderThan, scanTimeBusy, resolvePaths } from './common.js';
@@ -231,11 +231,32 @@ async function addBlockers(context, rule, item) {
231
231
  }
232
232
  }
233
233
 
234
+ const namesRule = (entry, rule) =>
235
+ entry === rule.id || entry.includes(`:${rule.id}:`);
236
+
237
+ /**
238
+ * Rules of `rules` that `only` entries name directly or through an item id
239
+ * (`env:rule:path`), when every entry names a global or version rule; null
240
+ * when an entry may match anything else and every rule has to run.
241
+ */
242
+ export function targetedRules(only = [], rules = GLOBAL_RULES) {
243
+ const targetable = [...GLOBAL_RULES, ...VERSION_RULES];
244
+ const covered =
245
+ only.length > 0 &&
246
+ only.every((entry) => targetable.some((rule) => namesRule(entry, rule)));
247
+ return covered
248
+ ? rules.filter((rule) => only.some((entry) => namesRule(entry, rule)))
249
+ : null;
250
+ }
251
+
234
252
  /**
235
253
  * Scans global cache and leftover locations.
236
254
  * @returns {Promise<object[]>} report items
237
255
  */
238
- export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
256
+ export async function scanGlobal(
257
+ context,
258
+ { rules = targetedRules(context.options.only) ?? GLOBAL_RULES } = {}
259
+ ) {
239
260
  const { env, options } = context;
240
261
  const homes = options.homes ?? (await env.homeDirs());
241
262
  const tmpDirs = options.tmpDirs ?? (await env.tmpDirs());
@@ -20,6 +20,7 @@ import { block, makeItem } from '../items.js';
20
20
  import { expandGlobPath, expandRulePath } from '../paths.js';
21
21
  import { scanTimeBusy, resolvePaths } from './common.js';
22
22
  import { configuredPattern } from '../env/cache-roots.js';
23
+ import { targetedRules } from './global.js';
23
24
 
24
25
  async function installDirs(env, rule, home) {
25
26
  const dirs = [];
@@ -265,6 +266,42 @@ async function blockGlobalPackages(env, rule, dir, item) {
265
266
  }
266
267
  }
267
268
 
269
+ /**
270
+ * Lock files a manager writes for every running process of a version
271
+ * (`rule.locks`, `{version}` replaced). A version stays while its lock names
272
+ * a live process, and while the lock or the process list cannot be read.
273
+ */
274
+ async function blockLiveLock(context, rule, home, version, item) {
275
+ if (!rule.locks) {
276
+ return;
277
+ }
278
+ const { env } = context;
279
+ const file = expandRulePath(rule.locks.replace('{version}', version), {
280
+ home,
281
+ pathApi: env.path,
282
+ });
283
+ if (!(await env.exists(file))) {
284
+ return;
285
+ }
286
+ let pid = null;
287
+ try {
288
+ pid = JSON.parse((await env.readText(file)) ?? '').pid;
289
+ } catch {
290
+ pid = null;
291
+ }
292
+ if (!Number.isInteger(pid) || pid <= 0) {
293
+ block(item, `${rule.manager} lock ${file} cannot be read`);
294
+ return;
295
+ }
296
+ context.lockProcesses ??= env.processes().catch(() => null);
297
+ const processes = await context.lockProcesses;
298
+ if (!processes) {
299
+ block(item, `cannot verify the process holding ${file}`);
300
+ } else if (processes.some((proc) => proc.pid === pid)) {
301
+ block(item, `locked by running ${rule.manager} (pid ${pid}, ${file})`);
302
+ }
303
+ }
304
+
268
305
  async function blockPyenvEnvironments(env, rule, dir, item) {
269
306
  if (rule.id !== 'pyenv-python') {
270
307
  return;
@@ -321,6 +358,7 @@ async function ruleItems(context, rule, home, pins) {
321
358
  });
322
359
  await blockPyenvEnvironments(env, rule, dir, item);
323
360
  await blockGlobalPackages(env, rule, dir, item);
361
+ await blockLiveLock(context, rule, home, version, item);
324
362
  const busy = await scanTimeBusy(context, item);
325
363
  if (busy) {
326
364
  block(item, `busy: ${busy}`);
@@ -334,7 +372,12 @@ async function ruleItems(context, rule, home, pins) {
334
372
  * Scans version manager installs.
335
373
  * @returns {Promise<object[]>} report items
336
374
  */
337
- export async function scanVersions(context, { rules = VERSION_RULES } = {}) {
375
+ export async function scanVersions(
376
+ context,
377
+ {
378
+ rules = targetedRules(context.options.only, VERSION_RULES) ?? VERSION_RULES,
379
+ } = {}
380
+ ) {
338
381
  const { env, options } = context;
339
382
  const homes = options.homes ?? (await env.homeDirs());
340
383
  const pins = await projectPins(