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 +20 -0
- package/README.md +13 -5
- package/package.json +1 -1
- package/src/clean.js +8 -1
- package/src/cli.js +2 -1
- package/src/rules/downloads.js +16 -6
- package/src/rules/versions.js +4 -0
- package/src/scan.js +39 -5
- package/src/scanners/global.js +23 -2
- package/src/scanners/versions.js +44 -1
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
|
|
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,
|
|
114
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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)
|
package/src/rules/downloads.js
CHANGED
|
@@ -31,13 +31,23 @@ export const DOWNLOAD_RULES = [
|
|
|
31
31
|
{
|
|
32
32
|
id: 'chrome-on-device-models',
|
|
33
33
|
ecosystem: 'browsers',
|
|
34
|
-
description:
|
|
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
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
|
|
40
|
-
|
|
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
|
},
|
package/src/rules/versions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|
package/src/scanners/global.js
CHANGED
|
@@ -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(
|
|
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());
|
package/src/scanners/versions.js
CHANGED
|
@@ -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(
|
|
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(
|