disk-space-saviour 0.15.2 → 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 CHANGED
@@ -1,5 +1,25 @@
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
+
9
+ ## 0.16.0
10
+
11
+ ### Minor Changes
12
+
13
+ - dddd5f6: Batch remote filesystem operations, add scan budgets and partial reports on
14
+ cancellation, discover containerd upperdirs, batch safe container deletions with
15
+ allocated-byte accounting, and retry process inspection by owner while reporting
16
+ blocked sizes and environment hints.
17
+
18
+ Find version-manager download caches and idle semver baseline builds, honor
19
+ configured cache roots, and watch host task health within its mount namespace.
20
+ Preserve scan and interrupted cleanup pre-scan reports when audit storage is
21
+ unwritable, and avoid root-owned default state under another user's home.
22
+
3
23
  ## 0.15.2
4
24
 
5
25
  ### Patch 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. The current rules rescan those items before deletion.
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
 
@@ -118,6 +125,7 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
118
125
  macOS) are skipped. So are projects with a running `cargo`, `gradle`,
119
126
  `npm`, `node` or similar tool, and anything modified within
120
127
  `--older-than` (1h).
128
+ Container paths are checked individually before each bounded deletion batch.
121
129
  - **Rust builds are pruned in two parts.** `cargo-superseded` removes
122
130
  older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
123
131
  `build/*`, `.fingerprint`) that a newer build replaced, but never while
@@ -135,6 +143,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
135
143
  environment stops at once, and the audit log and summary say which
136
144
  process was lost. A build that simply finishes during the cleanup also
137
145
  stops it: an exit cannot be told apart from a crash.
146
+ Host cleanup watches processes in its own mount namespace; each container
147
+ watches its own tasks. Processes with unknown namespace membership remain
148
+ watched.
138
149
  - **Git awareness.** Whole projects and containers are protected while they
139
150
  have unsaved changes, unpushed commits or stashes. Ignored dependency and
140
151
  build outputs can be cleaned despite unrelated source changes; tracked or
@@ -152,6 +163,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
152
163
  and left per environment, and disk usage before and after. Scan audits
153
164
  include every candidate and its blockers; blocked candidates do not
154
165
  contribute to reclaimable tier totals.
166
+ If a read-only scan cannot save its audit, it warns and still prints the
167
+ report. Root scans with another user's `HOME` use a private temporary audit
168
+ directory. Set `--audit-dir` to choose a persistent writable location.
155
169
 
156
170
  Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
157
171
  header caches to be cleaned. Active npx hashes remain protected through
@@ -165,6 +179,53 @@ and produces one environment hint. Old isolation logs and sanitized upload stagi
165
179
  are moderate-tier candidates. Opam download cleanup removes its cache
166
180
  directory directly and does not invoke `opam clean` or migrate the root.
167
181
 
182
+ Scans have a two-minute budget per filesystem environment, configurable with
183
+ `--scan-budget 5m` (library: `scanBudget`). Progress goes to stderr, leaving
184
+ JSON stdout clean. SIGINT and SIGTERM stop new work, cancel subprocesses and
185
+ print completed findings marked `aborted`; incomplete findings remain blocked.
186
+ The CLI exits 130 or 143 respectively. A second signal exits immediately.
187
+ Library callers can pass an `AbortSignal` and an `onScanProgress` callback.
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
+
221
+ Cache discovery honors tool configuration before default paths, including
222
+ `GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
223
+ `GRADLE_USER_HOME`, `DENO_DIR`, `BUN_INSTALL_CACHE_DIR` and `XDG_CACHE_HOME`.
224
+ Go, npm, pip, Yarn and pnpm cache queries are bounded and cached per environment.
225
+ Safe rules also cover perlbrew build/download trees, SDKMAN tmp/archive contents,
226
+ nvm downloads, idle `target/semver-checks`, and downloaded Ruby `.gem` files.
227
+ SDKMAN parent directories and installed gems are preserved.
228
+
168
229
  ## Docker
169
230
 
170
231
  With `--docker`, or automatically when a daemon is reachable, `dss` handles
@@ -188,6 +249,11 @@ three kinds of Docker data:
188
249
  this is denied or incomplete, cleanup stays blocked and names the
189
250
  unreadable processes. This also applies when dss runs inside a container
190
251
  with access to its Docker daemon.
252
+ Root probes also retry unreadable processes as their owning UID/GID with
253
+ `setpriv` when available. Readable evidence is retained after failed retries;
254
+ unresolved inspection produces one environment hint. Blocked totals show
255
+ measured bytes and separately identify visible bytes whose freeable size is
256
+ unknown.
191
257
  - **Stopped containers** are listed with their size, image, command, owner
192
258
  session (`HIVE_MIND_PARENT_SESSION_ID` and other session labels or
193
259
  environment variables, else a session id in the container name), task
@@ -223,6 +289,13 @@ memory headroom, and skips excessive candidate counts. Fresh Git checks before
223
289
  stopped-container removal still inspect new work. Verbose mode reports storage
224
290
  measurement progress. Host scans prune runtime, nested overlay, read-only and
225
291
  unselected mount points; explicitly selected ordinary mount roots are allowed.
292
+ For containerd-backed running containers, the daemon-side init process's
293
+ `/proc/<pid>/mountinfo` supplies the overlay upperdir when `GraphDriver` is absent.
294
+ JSON environments and the text summary show the storage source. Paths are
295
+ measured on the daemon host and removed inside the container so overlay
296
+ whiteouts are respected; cleanup reports the measured change in allocated
297
+ upperdir blocks when available. Remote filesystem operations use bounded
298
+ POSIX-shell batches and do not require Node inside the target container.
226
299
 
227
300
  Docker discovery uses a size-free container listing and measures each
228
301
  container independently. Failed size queries are retried once; objects
package/bin/dss.js CHANGED
@@ -3,14 +3,31 @@
3
3
  import { runCli } from '../src/cli.js';
4
4
 
5
5
  const controller = new AbortController();
6
- const abort = () => controller.abort();
7
- process.once('SIGINT', abort);
8
- process.once('SIGTERM', abort);
6
+ let received = null,
7
+ fallback;
8
+ const abort = (signal) => {
9
+ const code = signal === 'SIGTERM' ? 143 : 130;
10
+ if (received) {
11
+ process.exit(code);
12
+ }
13
+ received = code;
14
+ controller.abort(new Error(`interrupted by ${signal}`));
15
+ fallback = setTimeout(() => process.exit(code), 5000);
16
+ fallback.unref();
17
+ };
18
+ const interrupt = () => abort('SIGINT'),
19
+ terminate = () => abort('SIGTERM');
20
+ process.on('SIGINT', interrupt);
21
+ process.on('SIGTERM', terminate);
9
22
  try {
10
23
  process.exitCode = await runCli(process.argv.slice(2), {
11
24
  signal: controller.signal,
12
25
  });
26
+ if (received) {
27
+ process.exitCode = received;
28
+ }
13
29
  } finally {
14
- process.removeListener('SIGINT', abort);
15
- process.removeListener('SIGTERM', abort);
30
+ clearTimeout(fallback);
31
+ process.removeListener('SIGINT', interrupt);
32
+ process.removeListener('SIGTERM', terminate);
16
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.15.2",
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/audit.js CHANGED
@@ -12,6 +12,7 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
 
14
14
  export const AUDIT_SCHEMA = 1;
15
+ let privateAuditDirectory;
15
16
 
16
17
  export function throwIfAborted(options) {
17
18
  if (options.signal?.aborted) {
@@ -74,8 +75,24 @@ export function startAudit(command, fields = {}) {
74
75
  * @returns {Promise<string>}
75
76
  */
76
77
  export async function writeAudit(audit, options = {}) {
77
- const dir = auditDirectory(options);
78
- await fsp.mkdir(dir, { recursive: true });
78
+ let dir = auditDirectory(options);
79
+ // sudo/docker exec may retain another user's HOME. Do not leave root-owned
80
+ // default state there; explicit audit locations remain the caller's choice.
81
+ if (
82
+ !options.auditDir &&
83
+ !process.env.DSS_AUDIT_DIR &&
84
+ process.getuid?.() === 0
85
+ ) {
86
+ const home = os.homedir();
87
+ const owner = await fsp.stat(home).catch(() => null);
88
+ if (owner && owner.uid !== 0 && dir.startsWith(`${home}${path.sep}`)) {
89
+ privateAuditDirectory ??= fsp.mkdtemp(
90
+ path.join(os.tmpdir(), 'dss-audit-0-')
91
+ );
92
+ dir = await privateAuditDirectory;
93
+ }
94
+ }
95
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
79
96
  if (!options.inProgress) {
80
97
  audit.finishedAt ??= new Date().toISOString();
81
98
  }
package/src/clean.js CHANGED
@@ -31,6 +31,9 @@ import {
31
31
  import { imageNamed } from './docker/images.js';
32
32
  import { resolveEnvironment } from './env/resolve.js';
33
33
  import { configureScanPolicy } from './env/policy.js';
34
+ import { configureCacheRoots } from './env/cache-roots.js';
35
+ import { environmentScope, signalExecutor } from './env/scope.js';
36
+ import { batches } from './env/batches.js';
34
37
  import { GitInspector } from './git.js';
35
38
  import { HealthWatch } from './health.js';
36
39
  import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
@@ -41,6 +44,7 @@ import { OTHER_RULES } from './rules/other.js';
41
44
  import { compareVersions, VERSION_RULES } from './rules/versions.js';
42
45
  import { scanVersions } from './scanners/versions.js';
43
46
  import { analyzeRustProfile } from './scanners/rust.js';
47
+ import { currentProjectTier } from './scanners/projects.js';
44
48
  import {
45
49
  filterItems,
46
50
  revalidateReport,
@@ -158,7 +162,7 @@ export class Cleaner {
158
162
  this.report = report;
159
163
  this.options = options;
160
164
  this.contexts = new Map();
161
- this.health = new HealthWatch();
165
+ this.health = new HealthWatch(options.priorHealth);
162
166
  this.descriptors = new Map(
163
167
  (report.environments ?? []).map((env) => [env.id, env])
164
168
  );
@@ -219,6 +223,18 @@ export class Cleaner {
219
223
  return;
220
224
  }
221
225
  const ctx = this.context(item.env);
226
+ const executor = ctx.executor;
227
+ ctx.executor = signalExecutor(executor, this.options.signal);
228
+ try {
229
+ return await environmentScope(ctx.env, this.options.signal, () =>
230
+ this.#processScoped(ctx, item, entry)
231
+ );
232
+ } finally {
233
+ ctx.executor = executor;
234
+ }
235
+ }
236
+
237
+ async #processScoped(ctx, item, entry) {
222
238
  const fresh = await this.#recheck(ctx, item);
223
239
  if (fresh.reason) {
224
240
  entry.reason = fresh.reason;
@@ -356,7 +372,20 @@ export class Cleaner {
356
372
  }
357
373
 
358
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
+ }
359
384
  if (item.recheck?.type === 'version') {
385
+ if (!ctx.cacheRootsConfigured) {
386
+ await configureCacheRoots(ctx.env, await ctx.env.homeDirs());
387
+ ctx.cacheRootsConfigured = true;
388
+ }
360
389
  const rule = VERSION_RULES.find(
361
390
  (candidate) => candidate.id === item.rule
362
391
  );
@@ -366,6 +395,7 @@ export class Cleaner {
366
395
  options: {
367
396
  ...this.options,
368
397
  homes: [item.recheck.home],
398
+ freshSizes: true,
369
399
  roots: this.report.options.roots ?? [],
370
400
  },
371
401
  now: this.options.now(),
@@ -410,9 +440,18 @@ export class Cleaner {
410
440
  if (
411
441
  ctx.env.kind === 'host' &&
412
442
  type === 'remove' &&
413
- this.report.docker?.daemons?.some((daemon) => daemon.env === ctx.env.id)
443
+ (this.report.docker?.daemons?.some(
444
+ (daemon) => daemon.env === ctx.env.id
445
+ ) ||
446
+ this.options.streaming)
414
447
  ) {
415
- const mounts = await runningBindMounts(new DockerCli(ctx.executor));
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);
416
455
  for (const mount of mounts) {
417
456
  if (
418
457
  item.paths.some(
@@ -469,6 +508,9 @@ export class Cleaner {
469
508
  * item stops; an item checked path by path skips just the busy path.
470
509
  */
471
510
  async #removePaths(ctx, item, entry) {
511
+ if (ctx.env.kind === 'container' && item.checks?.perPath) {
512
+ return this.#removeBatches(ctx, item, entry);
513
+ }
472
514
  for (const target of item.action.paths) {
473
515
  if (this.options.signal?.aborted) {
474
516
  throw new Error('cleanup aborted');
@@ -490,18 +532,114 @@ export class Cleaner {
490
532
  entry.reason = boundary;
491
533
  return;
492
534
  }
493
- await ctx.env.remove([target]);
494
- ctx.env.writableLayer?.forget?.(target, ctx.env.path);
495
- entry.status = 'removed';
496
- entry.deletedPaths.push(target);
497
- entry.freedBytes += usage?.bytes ?? 0;
498
- await this.options.onProgress?.(entry);
535
+ await this.#deletePaths(ctx, entry, [target], usage?.bytes ?? 0);
499
536
  }
500
537
  if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
501
538
  entry.reason = 'every path was in use or recently written';
502
539
  }
503
540
  }
504
541
 
542
+ async #removeBatches(ctx, item, entry) {
543
+ for (const batch of batches(item.action.paths)) {
544
+ if (!(await this.#removeBatch(ctx, item, entry, batch))) {
545
+ return;
546
+ }
547
+ }
548
+ }
549
+
550
+ /** One short remote deletion per batch, after fresh health/liveness/boundary checks. */
551
+ async #removeBatch(ctx, item, entry, batch) {
552
+ await configureScanPolicy(ctx.env, this.options);
553
+ await ctx.liveness.refresh(true);
554
+ const stopped = this.health.verify(item, ctx.liveness.processList());
555
+ if (stopped) {
556
+ entry.reason = stopped;
557
+ return false;
558
+ }
559
+ await ctx.liveness.resolve(batch);
560
+ const usages = await ctx.env.usageMany(batch);
561
+ const { selected, reason } = this.#batchSelection(
562
+ ctx,
563
+ item,
564
+ entry,
565
+ batch,
566
+ usages
567
+ );
568
+ if (reason) {
569
+ entry.reason = reason;
570
+ return false;
571
+ }
572
+ if (!selected.length) {
573
+ return true;
574
+ }
575
+ await this.#deletePaths(ctx, entry, selected, sumBytes(usages, selected));
576
+ const lost = await this.health.check(ctx.env, item);
577
+ if (lost) {
578
+ entry.reason = lost;
579
+ }
580
+ return !lost;
581
+ }
582
+
583
+ #batchSelection(ctx, item, entry, batch, usages) {
584
+ const selected = [];
585
+ for (const target of batch) {
586
+ const blocker =
587
+ ctx.env.scanPolicy.removalReason(target) ??
588
+ ctx.liveness.busyReason({ ...item, paths: [target] });
589
+ if (blocker) {
590
+ return { selected: [], reason: blocker };
591
+ }
592
+ const usage = usages.get(target);
593
+ const busy = this.#pathActivity(ctx, item, target, usage);
594
+ const unavailable = this.#unavailableUsage(usage);
595
+ if (busy || unavailable) {
596
+ entry.skippedPaths.push({ path: target, reason: busy ?? unavailable });
597
+ } else {
598
+ selected.push(target);
599
+ }
600
+ }
601
+ return { selected };
602
+ }
603
+
604
+ #unavailableUsage(usage) {
605
+ if (!usage) {
606
+ return 'path disappeared';
607
+ }
608
+ if (usage.sizeUnknown) {
609
+ return 'writable-layer size unknown';
610
+ }
611
+ return usage.bytes === 0 ? 'no writable bytes remain' : null;
612
+ }
613
+
614
+ async #deletePaths(ctx, entry, selected, estimate) {
615
+ const before = await ctx.env.writableLayer?.allocatedBytes?.(
616
+ selected,
617
+ ctx.env.path
618
+ );
619
+ await ctx.env.remove(selected);
620
+ const after = await ctx.env.writableLayer?.allocatedBytes?.(
621
+ selected,
622
+ ctx.env.path
623
+ );
624
+ for (const target of selected) {
625
+ ctx.env.writableLayer?.forget?.(target, ctx.env.path);
626
+ }
627
+ entry.status = 'removed';
628
+ entry.deletedPaths.push(...selected);
629
+ entry.freedBytes += this.#freedBytes(ctx, entry, before, after, estimate);
630
+ await this.options.onProgress?.(entry);
631
+ }
632
+
633
+ #freedBytes(ctx, entry, before, after, fallback) {
634
+ if (typeof before === 'number' && typeof after === 'number') {
635
+ return Math.max(0, before - after);
636
+ }
637
+ if (ctx.env.writableLayer) {
638
+ entry.freedEstimated = true;
639
+ }
640
+ return fallback;
641
+ }
642
+
505
643
  #pathActivity(ctx, item, target, usage) {
506
644
  return (
507
645
  item.checks?.perPath &&
@@ -789,7 +927,8 @@ export async function clean(report, input = {}) {
789
927
  const audit = startAudit(input.command ?? 'clean', {
790
928
  dryRun: Boolean(options.dryRun),
791
929
  tier: options.tier,
792
- report: { createdAt: report.createdAt, totals: report.totals },
930
+ report,
931
+ entries: [...(input.priorEntries ?? [])],
793
932
  });
794
933
  const persistProgress = async (entry) => {
795
934
  if (!audit.entries.includes(entry)) {
@@ -809,7 +948,10 @@ export async function clean(report, input = {}) {
809
948
  await writeAudit(audit, { ...options, inProgress: true });
810
949
  }
811
950
  try {
812
- for (const item of planItems(report, options)) {
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
+ )) {
813
955
  if (options.signal?.aborted) {
814
956
  throw new Error('cleanup aborted');
815
957
  }