disk-space-saviour 0.16.0 → 0.18.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2c706f1: Treat every regenerable project directory as `safe` regardless of how recently
8
+ the project was used, and block it only while something uses it. Detect
9
+ processes that keep a directory on their `PATH`, stop a long-running Bun script
10
+ or Codex daemon from blocking caches they do not use, prune old Codex daemon
11
+ releases, resolve partial nvm pins to the newest matching install, keep Node.js
12
+ versions that hold user-installed global packages, inspect Git safety in linked
13
+ worktrees and submodules, and report per-phase scan timings.
14
+
15
+ ## 0.17.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 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.
20
+
3
21
  ## 0.16.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -30,17 +30,17 @@ dss emergency --free 20G --yes # escalate tiers until 20 GiB is free
30
30
  dss docker scan --recursive # containers and nested daemons
31
31
  ```
32
32
 
33
- Example (`dss scan ~/work` on two projects untouched for 40 days):
33
+ Example (`dss scan ~/work` on two projects):
34
34
 
35
35
  ```
36
36
  disk-space-saviour scan of host (build-01) in 3.7s (report only, nothing was deleted)
37
37
  /: 75.8 GiB free of 193 GiB (61% used)
38
38
 
39
- MODERATE 2 items, 7.6 MiB
39
+ SAFE 2 items, 7.6 MiB
40
40
  4.8 MiB cargo-target /home/me/work/api/target
41
41
  2.9 MiB node-modules /home/me/work/web/node_modules
42
42
 
43
- Reclaimable (cumulative): safe 0 B · moderate 7.6 MiB · aggressive 7.6 MiB; blocked 0 B
43
+ Reclaimable (cumulative): safe 7.6 MiB · moderate 7.6 MiB · aggressive 7.6 MiB; blocked 0 B
44
44
  Audit log: /home/me/.local/state/disk-space-saviour/audit/dss-scan-2026-09-26T00-31-05-432Z-232874.json
45
45
  Run `dss clean --tier safe` to see the plan, add --yes to delete.
46
46
  ```
@@ -76,25 +76,34 @@ 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
 
83
- Tiers are cumulative: `moderate` includes `safe`, and `aggressive` includes
84
- both.
90
+ Tiers are cumulative: `moderate` includes `safe`. Regenerable data is never
91
+ ranked by how recently it was used: a cache, build output or dependency
92
+ directory is `safe` and is held back only while something uses it (see
93
+ [Safety](#safety)).
85
94
 
86
- | Tier | What it removes |
87
- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
- | `safe` | Download and application caches, idle package-runner entries, old browser revisions, inactive lockfile-backed `node_modules`, dangling Docker images and build cache. Includes writable caches inside running containers. |
89
- | `moderate` | Whole `target/`, `.venv`, `build/` and similar folders of inactive projects; old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable); unused toolchain versions and tagged images; stopped containers. Container and tagged-image removal requires explicit consent. |
90
- | `aggressive` | For emergencies: everything regenerable, including build outputs of projects in active use. Only `dss emergency` reaches it. |
95
+ | Tier | What it removes |
96
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
+ | `safe` | Everything regenerable: download, runtime and application caches, idle package-runner entries, old browser revisions, `node_modules`, `vendor`, `target/`, `.venv`, `build/` and similar folders of any project, dangling Docker images and build cache. |
98
+ | `moderate` | Data that is disposable but not regenerated by a tool: old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable), unused toolchain versions (never one holding globally installed packages or virtual environments), tagged images and stopped containers (both require explicit consent). |
99
+ | `aggressive` | Accepted by `dss emergency` for compatibility; currently the same as `moderate`. |
91
100
 
92
101
  Covered ecosystems: JavaScript/TypeScript (npm, yarn, pnpm, bun, deno,
93
102
  framework build caches), Python, Rust, JVM (Gradle, Maven, Kotlin, Android),
94
103
  Go, C/C++ (CMake, ccache, vcpkg, Conan), .NET, PHP, Ruby, Swift/Xcode, Dart,
95
104
  Haskell, Scala, Elixir, OCaml, Lean, Julia, R, Zig, and scripting languages.
96
105
  Also covered: version managers (nvm, pyenv, rbenv, SDKMAN!, rustup, elan,
97
- ghcup, opam, swiftly, VS Code Server, Claude Code and Copilot CLI), Playwright, playwright-go, Puppeteer
106
+ ghcup, opam, swiftly, VS Code Server, Claude Code, the Codex daemon and Copilot CLI), Playwright, playwright-go, Puppeteer
98
107
  and Cypress browsers, HTTP caches of Chrome, Chromium, Edge, Brave, Opera,
99
108
  Opera GX, Vivaldi, Arc, Yandex Browser, Naver Whale, 360, QQ Browser, Firefox,
100
109
  LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
@@ -117,7 +126,19 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
117
126
  that a process holds open (`/proc/*/fd`, `cwd`, `exe`, or `lsof` on
118
127
  macOS) are skipped. So are projects with a running `cargo`, `gradle`,
119
128
  `npm`, `node` or similar tool, and anything modified within
120
- `--older-than` (1h).
129
+ `--older-than` (1h). How long ago a project was last used does not
130
+ matter. A tool running elsewhere does not block a runtime cache it is not
131
+ using: a script of an installed project keeps no lock on the Bun install
132
+ cache, while `bun install`, `bunx` or an auto-installing script does.
133
+ A directory on the `PATH` of a running process is kept too: the process
134
+ may still start programs from it.
135
+ - **Toolchain versions are kept while anything may need them.** The
136
+ manager's default and aliases, versions pinned by a project (`.nvmrc`,
137
+ `rust-toolchain.toml`, ...) and the newest install stay. A partial nvm pin
138
+ such as `20` keeps only the newest matching 20.x, as nvm resolves it. A
139
+ version holding globally installed npm packages or pyenv virtual
140
+ environments is blocked; bundled `npm`/`corepack` and use-m caches
141
+ (`<name>-v-<version>`) are reinstalled on demand and do not count.
121
142
  Container paths are checked individually before each bounded deletion batch.
122
143
  - **Rust builds are pruned in two parts.** `cargo-superseded` removes
123
144
  older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
@@ -179,6 +200,38 @@ print completed findings marked `aborted`; incomplete findings remain blocked.
179
200
  The CLI exits 130 or 143 respectively. A second signal exits immediately.
180
201
  Library callers can pass an `AbortSignal` and an `onScanProgress` callback.
181
202
 
203
+ Global caches run first, prioritizing rules that can use native sizing without
204
+ recursive activity timestamps, and emit findings as each rule finishes. Non-interactive
205
+ `clean` plans or deletes completed safe host caches while the remaining discovery
206
+ continues. `emergency` does the same, checks actual free space after each deletion,
207
+ and skips the remaining scan once the goal is met. Interactive runs show a plan
208
+ before asking for deletion consent. A later phase hitting its budget blocks its
209
+ partial findings while completed independent findings stay usable.
210
+
211
+ Project discovery uses eight directory workers, prunes matched generated trees,
212
+ Git objects, home media/Photos libraries, macOS Library and mounted volumes.
213
+ Source activity is checked separately without sizing entire project trees again.
214
+ Spotlight (`mdfind`) or `plocate`/`locate` supplies verified early hints when
215
+ available; the walk also runs because an index can miss files. Version-pin discovery
216
+ also prunes dependency/build trees.
217
+
218
+ The persistent `index/` stores directory listings and candidate measurements.
219
+ Each descendant directory's own mtime/ctime and identity is checked before reusing
220
+ its listing, so a nested addition is visible even when an ancestor did not change.
221
+ Unchanged directories avoid another listing; discovery still checks their metadata.
222
+ Sizing uses bounded workers and `du -skx` when no recursive activity timestamp is
223
+ needed. Sizes cached for up to an hour are labeled **estimated**, because changing
224
+ an existing descendant need not update the candidate's directory mtime. Cleanup
225
+ always bypasses that cache and rechecks processes, open files, Git, path boundaries
226
+ and sizes. Safe cleanup also rechecks source activity. Explicit moderate cleanup
227
+ preserves the ability to remove ignored dependencies when only source changed.
228
+ Library callers opt into state with `cacheDir`.
229
+
230
+ On macOS, reports include `sysctl vm.swapusage` and the largest resident/compressed
231
+ memory consumers from `top`. These are memory diagnostics, not verified per-process
232
+ swap ownership. When Docker Desktop consumes memory, saving work and restarting it
233
+ or lowering its VM memory limit can help. dss never deletes swap files.
234
+
182
235
  Cache discovery honors tool configuration before default paths, including
183
236
  `GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
184
237
  `GRADLE_USER_HOME`, `DENO_DIR`, `BUN_INSTALL_CACHE_DIR` and `XDG_CACHE_HOME`.
@@ -304,7 +357,7 @@ example and `examples/universal-app` for a report viewer.
304
357
  | `--audit-dir`, `DSS_AUDIT_DIR` | `$XDG_STATE_HOME/disk-space-saviour/audit` | Where audit logs go |
305
358
  | `--backup-dir`, `DSS_BACKUP_DIR` | `…/disk-space-saviour/backups` | Container log backups before `docker rm` |
306
359
  | `--older-than` | `1h` | Activity window; newer files are kept |
307
- | `--inactive` | `30d` | Project inactivity before `moderate` |
360
+ | `--inactive` | `30d` | Idle time before agent scratch is abandoned |
308
361
  | `--min-size` | `1M` | Smaller items are not reported |
309
362
  | `--depth` | `3` | Docker nesting depth |
310
363
  | `--journal-keep` | `512M` | journald size to keep |
package/bin/dss.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runCli } from '../src/cli.js';
3
+ // Scans are bound by file system calls, which run on libuv's thread pool
4
+ // (4 threads by default). It is sized on first use, so set it before any
5
+ // module that may touch the file system is loaded.
6
+ process.env.UV_THREADPOOL_SIZE ??= '32';
7
+ const { runCli } = await import('../src/cli.js');
4
8
 
5
9
  const controller = new AbortController();
6
10
  let received = null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.16.0",
3
+ "version": "0.18.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
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { finished } from 'node:stream/promises';
19
19
 
20
20
  import { backupDirectory, startAudit, writeAudit } from './audit.js';
21
+ import { PhaseTimer } from './timings.js';
21
22
  import { configureContainerHost } from './docker/writable.js';
22
23
  import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
23
24
  import {
@@ -161,7 +162,8 @@ 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);
166
+ this.timings = options.timings ?? new PhaseTimer();
165
167
  this.descriptors = new Map(
166
168
  (report.environments ?? []).map((env) => [env.id, env])
167
169
  );
@@ -234,7 +236,9 @@ export class Cleaner {
234
236
  }
235
237
 
236
238
  async #processScoped(ctx, item, entry) {
237
- const fresh = await this.#recheck(ctx, item);
239
+ const fresh = await this.timings.measure('recheck', () =>
240
+ this.#recheck(ctx, item)
241
+ );
238
242
  if (fresh.reason) {
239
243
  entry.reason = fresh.reason;
240
244
  return;
@@ -247,9 +251,15 @@ export class Cleaner {
247
251
  entry.status = 'planned';
248
252
  return;
249
253
  }
250
- await this.health.baseline(ctx.env, item);
251
- await this.#execute(ctx, fresh.item, entry);
252
- const lost = await this.health.check(ctx.env, item);
254
+ await this.timings.measure('health', () =>
255
+ this.health.baseline(ctx.env, item)
256
+ );
257
+ await this.timings.measure(`execute:${fresh.item.action?.type}`, () =>
258
+ this.#execute(ctx, fresh.item, entry)
259
+ );
260
+ const lost = await this.timings.measure('health', () =>
261
+ this.health.check(ctx.env, item)
262
+ );
253
263
  if (lost && !entry.reason) {
254
264
  entry.reason = lost;
255
265
  }
@@ -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((daemon) => daemon.env === ctx.env.id)
443
+ (this.report.docker?.daemons?.some(
444
+ (daemon) => daemon.env === ctx.env.id
445
+ ) ||
446
+ this.options.streaming)
433
447
  ) {
434
- 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);
435
455
  for (const mount of mounts) {
436
456
  if (
437
457
  item.paths.some(
@@ -902,19 +922,25 @@ export function cleanOptions(report, input) {
902
922
  * @returns {Promise<object>} audit log
903
923
  */
904
924
  export async function clean(report, input = {}) {
905
- report = await revalidateReport(report, input);
906
- const options = cleanOptions(report, input);
925
+ const timings = new PhaseTimer();
926
+ report = await timings.measure('revalidate', () =>
927
+ revalidateReport(report, input)
928
+ );
929
+ const options = { ...cleanOptions(report, input), timings };
907
930
  const audit = startAudit(input.command ?? 'clean', {
908
931
  dryRun: Boolean(options.dryRun),
909
932
  tier: options.tier,
910
- report: { createdAt: report.createdAt, totals: report.totals },
933
+ report,
934
+ entries: [...(input.priorEntries ?? [])],
911
935
  });
912
936
  const persistProgress = async (entry) => {
913
937
  if (!audit.entries.includes(entry)) {
914
938
  audit.entries.push(entry);
915
939
  }
916
940
  if (options.audit !== false) {
917
- await writeAudit(audit, { ...options, inProgress: true });
941
+ await timings.measure('audit-write', () =>
942
+ writeAudit(audit, { ...options, inProgress: true })
943
+ );
918
944
  }
919
945
  };
920
946
  const cleaner = new Cleaner(report, {
@@ -923,11 +949,15 @@ export async function clean(report, input = {}) {
923
949
  onProgress: persistProgress,
924
950
  });
925
951
  audit.health = cleaner.health.records;
952
+ audit.timings = timings;
926
953
  if (options.audit !== false) {
927
954
  await writeAudit(audit, { ...options, inProgress: true });
928
955
  }
929
956
  try {
930
- for (const item of planItems(report, options)) {
957
+ const done = new Set(audit.entries.map((entry) => entry.id));
958
+ for (const item of planItems(report, options).filter(
959
+ (item) => !done.has(item.id)
960
+ )) {
931
961
  if (options.signal?.aborted) {
932
962
  throw new Error('cleanup aborted');
933
963
  }
@@ -937,7 +967,9 @@ export async function clean(report, input = {}) {
937
967
  }
938
968
  options.onEntry?.(entry, item);
939
969
  if (options.audit !== false) {
940
- await writeAudit(audit, { ...options, inProgress: true });
970
+ await timings.measure('audit-write', () =>
971
+ writeAudit(audit, { ...options, inProgress: true })
972
+ );
941
973
  }
942
974
  }
943
975
  } catch (error) {
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]
@@ -43,7 +45,7 @@ Scope:
43
45
  --scanner NAME projects, global, versions, agents, system
44
46
  --min-size SIZE ignore smaller items (default 1M)
45
47
  --older-than AGE activity window, newer files are kept (1h)
46
- --inactive AGE project inactivity for moderate tier (30d)
48
+ --inactive AGE idle time before agent scratch counts as abandoned (30d)
47
49
  --journal-keep SIZE journald size to keep (512M)
48
50
 
49
51
  Goals (emergency):
@@ -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
- if (!values.report) {
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
- return JSON.parse(await fsp.readFile(values.report, 'utf8'));
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} items (${progress.status})`
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),
@@ -634,6 +634,7 @@ class DockerScan {
634
634
  scanStatus: env.scanStatus,
635
635
  hint: env.probeHint ?? null,
636
636
  layerSource: env.writableLayer?.source ?? null,
637
+ timings: env.timings?.toJSON() ?? [],
637
638
  });
638
639
  if (await env.which('docker')) {
639
640
  try {
package/src/emergency.js CHANGED
@@ -18,6 +18,7 @@ import { LocalEnv } from './env/local.js';
18
18
  import { TIERS, selectByTier, tierRank } from './items.js';
19
19
  import { resolveGoal, resolveOptions } from './options.js';
20
20
  import { revalidateReport, scan } from './scan.js';
21
+ import { PhaseTimer } from './timings.js';
21
22
 
22
23
  /**
23
24
  * Used percentage the way `df` prints it: used / (used + available).
@@ -86,15 +87,45 @@ export async function emergency(input = {}) {
86
87
  path: target,
87
88
  diskBefore,
88
89
  bytesNeeded: bytesNeeded(goal, diskBefore),
90
+ // Shared by every cleanup pass of a streamed scan.
91
+ timings: new PhaseTimer(),
89
92
  });
90
93
  const report = input.report
91
94
  ? await revalidateReport(input.report, { ...input, env })
92
- : await scan({ ...input, env });
93
- audit.report = { createdAt: report.createdAt, totals: report.totals };
95
+ : goalMet(goal, diskBefore)
96
+ ? {
97
+ createdAt: new Date(options.now()).toISOString(),
98
+ items: [],
99
+ environments: [{ id: env.id, label: env.label }],
100
+ totals: {},
101
+ }
102
+ : await scan({
103
+ ...input,
104
+ env,
105
+ onScanPhase: async (phase) => {
106
+ audit.diskAfter = await escalate({
107
+ report: phase,
108
+ options: {
109
+ ...options,
110
+ tier: 'safe',
111
+ streaming: options.docker !== false,
112
+ },
113
+ env,
114
+ target,
115
+ goal,
116
+ audit,
117
+ });
118
+ return !goalMet(goal, audit.diskAfter);
119
+ },
120
+ });
121
+ audit.report = report;
94
122
  if (options.audit !== false) {
95
123
  await writeAudit(audit, { ...options, inProgress: true });
96
124
  }
97
- let disk = diskBefore;
125
+ let disk = audit.diskAfter ?? diskBefore;
126
+ if (report.aborted) {
127
+ audit.scanReport = report;
128
+ }
98
129
  try {
99
130
  if (!goalMet(goal, disk)) {
100
131
  disk = await escalate({ report, options, env, target, goal, audit });
@@ -121,9 +152,11 @@ async function escalate({ report, options, env, target, goal, audit }) {
121
152
  ...options,
122
153
  env,
123
154
  onProgress: persistProgress,
155
+ priorHealth: audit.health,
156
+ timings: audit.timings,
124
157
  });
125
158
  audit.health = cleaner.health.records;
126
- const done = new Set();
159
+ const done = new Set(audit.entries.map((entry) => entry.id));
127
160
  const targetDevice = await env.deviceId?.(target);
128
161
  const scoped = await Promise.all(
129
162
  wantedItems(report, options).map(async (item) => {
@@ -141,8 +174,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
141
174
  })
142
175
  );
143
176
  const wanted = scoped.filter(Boolean);
144
- let disk = audit.diskBefore;
145
- let freed = 0;
177
+ let disk = audit.diskAfter ?? audit.diskBefore;
178
+ let freed = audit.entries
179
+ .filter((entry) => entry.status === 'planned')
180
+ .reduce((sum, entry) => sum + entry.plannedBytes, 0);
146
181
  for (const tier of TIERS.slice(0, tierRank(options.tier) + 1)) {
147
182
  audit.reachedTier = tier;
148
183
  const items = selectByTier(wanted, tier)