disk-space-saviour 0.16.0 → 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.
- package/CHANGELOG.md +6 -0
- package/README.md +40 -1
- package/package.json +1 -1
- package/src/clean.js +29 -5
- package/src/cli.js +79 -13
- package/src/emergency.js +37 -6
- package/src/env/local.js +245 -64
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +41 -1
- package/src/fast-clean.js +39 -0
- package/src/health.js +3 -3
- package/src/index.d.ts +27 -0
- package/src/options.js +3 -1
- package/src/report.js +18 -2
- package/src/scan-state.js +239 -0
- package/src/scan.js +135 -19
- package/src/scanners/global.js +19 -2
- package/src/scanners/projects.js +89 -20
- package/src/scanners/versions.js +7 -4
- package/src/swap.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a518d7b: Reuse recent authenticated scans, stream safe global-cache cleanup before project discovery, and stop emergency scans as soon as the free-space goal is met. Add bounded discovery, persistent directory and size indexes, separate candidate/sizing progress counters, and macOS swap diagnostics while retaining fresh deletion checks.
|
|
8
|
+
|
|
3
9
|
## 0.16.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -76,7 +76,14 @@ Run `dss --help` for every option. The most useful ones:
|
|
|
76
76
|
- `--scanner NAME` restricts scanning to `projects`, `global`, `versions`,
|
|
77
77
|
`agents` or `system`.
|
|
78
78
|
- `--report FILE` limits cleaning to items in a saved `dss scan --json`
|
|
79
|
-
report.
|
|
79
|
+
report. `--from FILE` also accepts an audit JSON containing the full report.
|
|
80
|
+
Recent reports authenticated by this installation are reused; older, unsigned
|
|
81
|
+
or edited reports require discovery again. Changing discovery or action options
|
|
82
|
+
also rebuilds the plan. Every deletion still gets fresh checks.
|
|
83
|
+
- `clean` automatically reuses a matching scan from the last 30 minutes.
|
|
84
|
+
`--fresh` bypasses this plan and the discovery/size index; `--cache-dir DIR`
|
|
85
|
+
chooses the state location (default `$XDG_STATE_HOME/disk-space-saviour`, or
|
|
86
|
+
`~/.local/state/disk-space-saviour`).
|
|
80
87
|
|
|
81
88
|
## Tiers
|
|
82
89
|
|
|
@@ -179,6 +186,38 @@ print completed findings marked `aborted`; incomplete findings remain blocked.
|
|
|
179
186
|
The CLI exits 130 or 143 respectively. A second signal exits immediately.
|
|
180
187
|
Library callers can pass an `AbortSignal` and an `onScanProgress` callback.
|
|
181
188
|
|
|
189
|
+
Global caches run first, prioritizing rules that can use native sizing without
|
|
190
|
+
recursive activity timestamps, and emit findings as each rule finishes. Non-interactive
|
|
191
|
+
`clean` plans or deletes completed safe host caches while the remaining discovery
|
|
192
|
+
continues. `emergency` does the same, checks actual free space after each deletion,
|
|
193
|
+
and skips the remaining scan once the goal is met. Interactive runs show a plan
|
|
194
|
+
before asking for deletion consent. A later phase hitting its budget blocks its
|
|
195
|
+
partial findings while completed independent findings stay usable.
|
|
196
|
+
|
|
197
|
+
Project discovery uses eight directory workers, prunes matched generated trees,
|
|
198
|
+
Git objects, home media/Photos libraries, macOS Library and mounted volumes.
|
|
199
|
+
Source activity is checked separately without sizing entire project trees again.
|
|
200
|
+
Spotlight (`mdfind`) or `plocate`/`locate` supplies verified early hints when
|
|
201
|
+
available; the walk also runs because an index can miss files. Version-pin discovery
|
|
202
|
+
also prunes dependency/build trees.
|
|
203
|
+
|
|
204
|
+
The persistent `index/` stores directory listings and candidate measurements.
|
|
205
|
+
Each descendant directory's own mtime/ctime and identity is checked before reusing
|
|
206
|
+
its listing, so a nested addition is visible even when an ancestor did not change.
|
|
207
|
+
Unchanged directories avoid another listing; discovery still checks their metadata.
|
|
208
|
+
Sizing uses bounded workers and `du -skx` when no recursive activity timestamp is
|
|
209
|
+
needed. Sizes cached for up to an hour are labeled **estimated**, because changing
|
|
210
|
+
an existing descendant need not update the candidate's directory mtime. Cleanup
|
|
211
|
+
always bypasses that cache and rechecks processes, open files, Git, path boundaries
|
|
212
|
+
and sizes. Safe cleanup also rechecks source activity. Explicit moderate cleanup
|
|
213
|
+
preserves the ability to remove ignored dependencies when only source changed.
|
|
214
|
+
Library callers opt into state with `cacheDir`.
|
|
215
|
+
|
|
216
|
+
On macOS, reports include `sysctl vm.swapusage` and the largest resident/compressed
|
|
217
|
+
memory consumers from `top`. These are memory diagnostics, not verified per-process
|
|
218
|
+
swap ownership. When Docker Desktop consumes memory, saving work and restarting it
|
|
219
|
+
or lowering its VM memory limit can help. dss never deletes swap files.
|
|
220
|
+
|
|
182
221
|
Cache discovery honors tool configuration before default paths, including
|
|
183
222
|
`GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
|
|
184
223
|
`GRADLE_USER_HOME`, `DENO_DIR`, `BUN_INSTALL_CACHE_DIR` and `XDG_CACHE_HOME`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "disk-space-saviour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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
|
@@ -44,6 +44,7 @@ import { OTHER_RULES } from './rules/other.js';
|
|
|
44
44
|
import { compareVersions, VERSION_RULES } from './rules/versions.js';
|
|
45
45
|
import { scanVersions } from './scanners/versions.js';
|
|
46
46
|
import { analyzeRustProfile } from './scanners/rust.js';
|
|
47
|
+
import { currentProjectTier } from './scanners/projects.js';
|
|
47
48
|
import {
|
|
48
49
|
filterItems,
|
|
49
50
|
revalidateReport,
|
|
@@ -161,7 +162,7 @@ export class Cleaner {
|
|
|
161
162
|
this.report = report;
|
|
162
163
|
this.options = options;
|
|
163
164
|
this.contexts = new Map();
|
|
164
|
-
this.health = new HealthWatch();
|
|
165
|
+
this.health = new HealthWatch(options.priorHealth);
|
|
165
166
|
this.descriptors = new Map(
|
|
166
167
|
(report.environments ?? []).map((env) => [env.id, env])
|
|
167
168
|
);
|
|
@@ -371,6 +372,15 @@ export class Cleaner {
|
|
|
371
372
|
}
|
|
372
373
|
|
|
373
374
|
async #staticBlocker(ctx, item) {
|
|
375
|
+
if (item.project && this.options.tier === 'safe') {
|
|
376
|
+
const tier = await currentProjectTier(
|
|
377
|
+
{ ...ctx, options: this.options, now: this.options.now() },
|
|
378
|
+
item
|
|
379
|
+
);
|
|
380
|
+
if (tierRank(tier) > tierRank(this.options.tier)) {
|
|
381
|
+
return `project recently active; requires the ${tier} tier`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
374
384
|
if (item.recheck?.type === 'version') {
|
|
375
385
|
if (!ctx.cacheRootsConfigured) {
|
|
376
386
|
await configureCacheRoots(ctx.env, await ctx.env.homeDirs());
|
|
@@ -385,6 +395,7 @@ export class Cleaner {
|
|
|
385
395
|
options: {
|
|
386
396
|
...this.options,
|
|
387
397
|
homes: [item.recheck.home],
|
|
398
|
+
freshSizes: true,
|
|
388
399
|
roots: this.report.options.roots ?? [],
|
|
389
400
|
},
|
|
390
401
|
now: this.options.now(),
|
|
@@ -429,9 +440,18 @@ export class Cleaner {
|
|
|
429
440
|
if (
|
|
430
441
|
ctx.env.kind === 'host' &&
|
|
431
442
|
type === 'remove' &&
|
|
432
|
-
this.report.docker?.daemons?.some(
|
|
443
|
+
(this.report.docker?.daemons?.some(
|
|
444
|
+
(daemon) => daemon.env === ctx.env.id
|
|
445
|
+
) ||
|
|
446
|
+
this.options.streaming)
|
|
433
447
|
) {
|
|
434
|
-
const
|
|
448
|
+
const docker = new DockerCli(ctx.executor);
|
|
449
|
+
if (this.options.streaming && !(await docker.info())) {
|
|
450
|
+
return this.options.docker === true
|
|
451
|
+
? 'cannot verify running Docker bind mounts'
|
|
452
|
+
: null;
|
|
453
|
+
}
|
|
454
|
+
const mounts = await runningBindMounts(docker);
|
|
435
455
|
for (const mount of mounts) {
|
|
436
456
|
if (
|
|
437
457
|
item.paths.some(
|
|
@@ -907,7 +927,8 @@ export async function clean(report, input = {}) {
|
|
|
907
927
|
const audit = startAudit(input.command ?? 'clean', {
|
|
908
928
|
dryRun: Boolean(options.dryRun),
|
|
909
929
|
tier: options.tier,
|
|
910
|
-
report
|
|
930
|
+
report,
|
|
931
|
+
entries: [...(input.priorEntries ?? [])],
|
|
911
932
|
});
|
|
912
933
|
const persistProgress = async (entry) => {
|
|
913
934
|
if (!audit.entries.includes(entry)) {
|
|
@@ -927,7 +948,10 @@ export async function clean(report, input = {}) {
|
|
|
927
948
|
await writeAudit(audit, { ...options, inProgress: true });
|
|
928
949
|
}
|
|
929
950
|
try {
|
|
930
|
-
|
|
951
|
+
const done = new Set(audit.entries.map((entry) => entry.id));
|
|
952
|
+
for (const item of planItems(report, options).filter(
|
|
953
|
+
(item) => !done.has(item.id)
|
|
954
|
+
)) {
|
|
931
955
|
if (options.signal?.aborted) {
|
|
932
956
|
throw new Error('cleanup aborted');
|
|
933
957
|
}
|
package/src/cli.js
CHANGED
|
@@ -15,9 +15,11 @@ import { startAudit, writeAudit } from './audit.js';
|
|
|
15
15
|
import { clean } from './clean.js';
|
|
16
16
|
import { emergency } from './emergency.js';
|
|
17
17
|
import { setTrace } from './exec.js';
|
|
18
|
-
import { resolveGoal } from './options.js';
|
|
18
|
+
import { resolveGoal, resolveOptions } from './options.js';
|
|
19
19
|
import { formatAudit, formatReport } from './report.js';
|
|
20
20
|
import { scan } from './scan.js';
|
|
21
|
+
import { cleanDuringScan } from './fast-clean.js';
|
|
22
|
+
import { defaultStateDirectory, loadScan } from './scan-state.js';
|
|
21
23
|
import { formatBytes } from './units.js';
|
|
22
24
|
|
|
23
25
|
export const USAGE = `Usage: dss <command> [paths...] [options]
|
|
@@ -67,6 +69,9 @@ Consent:
|
|
|
67
69
|
Output:
|
|
68
70
|
--json print the report or audit log as JSON
|
|
69
71
|
--report FILE clean: use a report saved by scan --json
|
|
72
|
+
--from FILE clean/emergency: reuse a scan or audit JSON
|
|
73
|
+
--fresh bypass the recent scan and persistent index
|
|
74
|
+
--cache-dir DIR scan state (default: XDG state directory)
|
|
70
75
|
--audit-dir DIR audit logs ($DSS_AUDIT_DIR)
|
|
71
76
|
--backup-dir DIR container log backups ($DSS_BACKUP_DIR)
|
|
72
77
|
--verbose list everything and trace commands (DSS_DEBUG=1)
|
|
@@ -106,6 +111,9 @@ const OPTIONS = {
|
|
|
106
111
|
'allow-dirty-container': { type: 'string', multiple: true },
|
|
107
112
|
'no-native': { type: 'boolean' },
|
|
108
113
|
report: { type: 'string' },
|
|
114
|
+
from: { type: 'string' },
|
|
115
|
+
fresh: { type: 'boolean' },
|
|
116
|
+
'cache-dir': { type: 'string' },
|
|
109
117
|
'audit-dir': { type: 'string' },
|
|
110
118
|
'backup-dir': { type: 'string' },
|
|
111
119
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -182,6 +190,9 @@ export function toOptions(values, paths = []) {
|
|
|
182
190
|
noNative: values['no-native'],
|
|
183
191
|
auditDir: values['audit-dir'],
|
|
184
192
|
backupDir: values['backup-dir'],
|
|
193
|
+
cacheDir: values.fresh
|
|
194
|
+
? false
|
|
195
|
+
: (values['cache-dir'] ?? defaultStateDirectory()),
|
|
185
196
|
};
|
|
186
197
|
}
|
|
187
198
|
|
|
@@ -240,14 +251,7 @@ function dockerOnly(options, values) {
|
|
|
240
251
|
export function scanAudit(report) {
|
|
241
252
|
return startAudit('scan', {
|
|
242
253
|
dryRun: true,
|
|
243
|
-
report
|
|
244
|
-
createdAt: report.createdAt,
|
|
245
|
-
durationMs: report.durationMs,
|
|
246
|
-
totals: report.totals,
|
|
247
|
-
environments: report.environments.map((env) => env.id),
|
|
248
|
-
errors: report.errors,
|
|
249
|
-
items: report.items,
|
|
250
|
-
},
|
|
254
|
+
report,
|
|
251
255
|
entries: report.items.map((item) => ({
|
|
252
256
|
id: item.id,
|
|
253
257
|
env: item.env,
|
|
@@ -283,7 +287,7 @@ export function processIo() {
|
|
|
283
287
|
};
|
|
284
288
|
}
|
|
285
289
|
|
|
286
|
-
const API = { scan, clean, emergency };
|
|
290
|
+
const API = { scan, clean, emergency, cleanDuringScan };
|
|
287
291
|
|
|
288
292
|
class Cli {
|
|
289
293
|
constructor(io, api) {
|
|
@@ -321,10 +325,19 @@ class Cli {
|
|
|
321
325
|
}
|
|
322
326
|
|
|
323
327
|
async loadReport(options, values) {
|
|
324
|
-
|
|
328
|
+
const file = values.from ?? values.report;
|
|
329
|
+
if (!file) {
|
|
330
|
+
const recent = await loadScan(resolveOptions(options));
|
|
331
|
+
if (recent) {
|
|
332
|
+
this.io.stderr(
|
|
333
|
+
`Reusing scan from ${recent.createdAt}; planned items will be checked before deletion.`
|
|
334
|
+
);
|
|
335
|
+
return recent;
|
|
336
|
+
}
|
|
325
337
|
return this.api.scan(options);
|
|
326
338
|
}
|
|
327
|
-
|
|
339
|
+
const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
340
|
+
return saved.report?.schema ? saved.report : saved;
|
|
328
341
|
}
|
|
329
342
|
|
|
330
343
|
confirmItem() {
|
|
@@ -371,6 +384,33 @@ class Cli {
|
|
|
371
384
|
}
|
|
372
385
|
|
|
373
386
|
async clean(options, values) {
|
|
387
|
+
if (
|
|
388
|
+
this.api.cleanDuringScan &&
|
|
389
|
+
!values.report &&
|
|
390
|
+
!values.from &&
|
|
391
|
+
!this.io.interactive
|
|
392
|
+
) {
|
|
393
|
+
const recent = await loadScan(resolveOptions(options));
|
|
394
|
+
if (!recent) {
|
|
395
|
+
const result = await this.consent(values, (extra) =>
|
|
396
|
+
this.api.cleanDuringScan({
|
|
397
|
+
...options,
|
|
398
|
+
...extra,
|
|
399
|
+
streaming: options.docker !== false,
|
|
400
|
+
})
|
|
401
|
+
);
|
|
402
|
+
if (result.report.aborted) {
|
|
403
|
+
return this.printScan(result.report, options, values);
|
|
404
|
+
}
|
|
405
|
+
this.print(values, result.audit, () =>
|
|
406
|
+
formatAudit(result.audit, { verbose: values.verbose })
|
|
407
|
+
);
|
|
408
|
+
return result.audit.aborted ||
|
|
409
|
+
result.audit.entries.some((entry) => entry.status === 'failed')
|
|
410
|
+
? 1
|
|
411
|
+
: 0;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
374
414
|
const report = await this.loadReport(options, values);
|
|
375
415
|
if (report.aborted) {
|
|
376
416
|
return this.printScan(report, options, values);
|
|
@@ -393,6 +433,32 @@ class Cli {
|
|
|
393
433
|
} catch (error) {
|
|
394
434
|
throw new UsageError(error.message);
|
|
395
435
|
}
|
|
436
|
+
if (
|
|
437
|
+
this.api === API &&
|
|
438
|
+
!values.report &&
|
|
439
|
+
!values.from &&
|
|
440
|
+
!this.io.interactive
|
|
441
|
+
) {
|
|
442
|
+
const recent = await loadScan(resolveOptions(options));
|
|
443
|
+
const audit = await this.consent(values, (extra) =>
|
|
444
|
+
this.api.emergency({
|
|
445
|
+
...options,
|
|
446
|
+
...(recent ? { report: recent } : {}),
|
|
447
|
+
...extra,
|
|
448
|
+
})
|
|
449
|
+
);
|
|
450
|
+
if (audit.scanReport?.aborted) {
|
|
451
|
+
return this.printScan(audit.scanReport, options, values);
|
|
452
|
+
}
|
|
453
|
+
this.print(values, audit, () =>
|
|
454
|
+
formatAudit(audit, { verbose: values.verbose })
|
|
455
|
+
);
|
|
456
|
+
return audit.entries.some((entry) => entry.status === 'failed')
|
|
457
|
+
? 1
|
|
458
|
+
: audit.goalMet
|
|
459
|
+
? 0
|
|
460
|
+
: 3;
|
|
461
|
+
}
|
|
396
462
|
const report = await this.loadReport(options, values);
|
|
397
463
|
if (report.aborted) {
|
|
398
464
|
return this.printScan(report, options, values);
|
|
@@ -461,7 +527,7 @@ export async function runCli(argv, deps = {}) {
|
|
|
461
527
|
}
|
|
462
528
|
options.onScanProgress = (progress) =>
|
|
463
529
|
io.stderr(
|
|
464
|
-
`[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.items}
|
|
530
|
+
`[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.candidates ?? progress.items} candidates, ${progress.filesSized ?? 0} files sized, ${progress.directoriesSized ?? 0} directories sized (${progress.status})`
|
|
465
531
|
);
|
|
466
532
|
return await dispatch(
|
|
467
533
|
new Cli(io, deps.api ?? API),
|
package/src/emergency.js
CHANGED
|
@@ -89,12 +89,40 @@ export async function emergency(input = {}) {
|
|
|
89
89
|
});
|
|
90
90
|
const report = input.report
|
|
91
91
|
? await revalidateReport(input.report, { ...input, env })
|
|
92
|
-
:
|
|
93
|
-
|
|
92
|
+
: goalMet(goal, diskBefore)
|
|
93
|
+
? {
|
|
94
|
+
createdAt: new Date(options.now()).toISOString(),
|
|
95
|
+
items: [],
|
|
96
|
+
environments: [{ id: env.id, label: env.label }],
|
|
97
|
+
totals: {},
|
|
98
|
+
}
|
|
99
|
+
: await scan({
|
|
100
|
+
...input,
|
|
101
|
+
env,
|
|
102
|
+
onScanPhase: async (phase) => {
|
|
103
|
+
audit.diskAfter = await escalate({
|
|
104
|
+
report: phase,
|
|
105
|
+
options: {
|
|
106
|
+
...options,
|
|
107
|
+
tier: 'safe',
|
|
108
|
+
streaming: options.docker !== false,
|
|
109
|
+
},
|
|
110
|
+
env,
|
|
111
|
+
target,
|
|
112
|
+
goal,
|
|
113
|
+
audit,
|
|
114
|
+
});
|
|
115
|
+
return !goalMet(goal, audit.diskAfter);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
audit.report = report;
|
|
94
119
|
if (options.audit !== false) {
|
|
95
120
|
await writeAudit(audit, { ...options, inProgress: true });
|
|
96
121
|
}
|
|
97
|
-
let disk = diskBefore;
|
|
122
|
+
let disk = audit.diskAfter ?? diskBefore;
|
|
123
|
+
if (report.aborted) {
|
|
124
|
+
audit.scanReport = report;
|
|
125
|
+
}
|
|
98
126
|
try {
|
|
99
127
|
if (!goalMet(goal, disk)) {
|
|
100
128
|
disk = await escalate({ report, options, env, target, goal, audit });
|
|
@@ -121,9 +149,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
|
|
|
121
149
|
...options,
|
|
122
150
|
env,
|
|
123
151
|
onProgress: persistProgress,
|
|
152
|
+
priorHealth: audit.health,
|
|
124
153
|
});
|
|
125
154
|
audit.health = cleaner.health.records;
|
|
126
|
-
const done = new Set();
|
|
155
|
+
const done = new Set(audit.entries.map((entry) => entry.id));
|
|
127
156
|
const targetDevice = await env.deviceId?.(target);
|
|
128
157
|
const scoped = await Promise.all(
|
|
129
158
|
wantedItems(report, options).map(async (item) => {
|
|
@@ -141,8 +170,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
|
|
|
141
170
|
})
|
|
142
171
|
);
|
|
143
172
|
const wanted = scoped.filter(Boolean);
|
|
144
|
-
let disk = audit.diskBefore;
|
|
145
|
-
let freed =
|
|
173
|
+
let disk = audit.diskAfter ?? audit.diskBefore;
|
|
174
|
+
let freed = audit.entries
|
|
175
|
+
.filter((entry) => entry.status === 'planned')
|
|
176
|
+
.reduce((sum, entry) => sum + entry.plannedBytes, 0);
|
|
146
177
|
for (const tier of TIERS.slice(0, tierRank(options.tier) + 1)) {
|
|
147
178
|
audit.reachedTier = tier;
|
|
148
179
|
const items = selectByTier(wanted, tier)
|