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 +18 -0
- package/README.md +67 -14
- package/bin/dss.js +5 -1
- package/package.json +1 -1
- package/src/clean.js +45 -13
- package/src/cli.js +80 -14
- package/src/docker/scan.js +1 -0
- package/src/emergency.js +41 -6
- package/src/env/local.js +270 -65
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +10 -1
- package/src/fast-clean.js +39 -0
- package/src/git.js +101 -39
- package/src/health.js +3 -3
- package/src/index.d.ts +28 -1
- package/src/liveness.js +131 -9
- package/src/options.js +3 -1
- package/src/report.js +69 -2
- package/src/rules/ecosystems.js +5 -4
- package/src/rules/other.js +4 -1
- package/src/rules/versions.js +23 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +157 -22
- package/src/scanners/global.js +46 -11
- package/src/scanners/projects.js +94 -69
- package/src/scanners/versions.js +109 -7
- package/src/swap.js +69 -0
- package/src/timings.js +54 -0
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
84
|
-
|
|
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` |
|
|
89
|
-
| `moderate` |
|
|
90
|
-
| `aggressive` |
|
|
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` |
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
251
|
-
|
|
252
|
-
|
|
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(
|
|
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(
|
|
@@ -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
|
-
|
|
906
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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/docker/scan.js
CHANGED
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
|
-
:
|
|
93
|
-
|
|
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 =
|
|
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)
|