disk-space-saviour 0.0.0-stage → 0.12.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/src/clean.js ADDED
@@ -0,0 +1,716 @@
1
+ /**
2
+ * `clean(report, options) → AuditLog`: acts on the items of a report.
3
+ *
4
+ * Before touching an item the cleaner re-checks it against the live
5
+ * system: it re-measures the paths, refreshes the process and open-file
6
+ * probe, re-runs the Git checks and, for superseded Rust artifacts,
7
+ * re-derives which hashes are superseded. Stopped containers are
8
+ * re-inspected (still stopped, not restarted since the scan, Git state
9
+ * still clean) and their logs and inspect data are saved before a plain
10
+ * `docker rm`. Long-running processes of each environment are watched
11
+ * across the cleanup (see ./health.js). Every decision lands in the audit
12
+ * log.
13
+ */
14
+
15
+ import { once } from 'node:events';
16
+ import { createWriteStream, promises as fsp } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { finished } from 'node:stream/promises';
19
+
20
+ import { backupDirectory, startAudit, writeAudit } from './audit.js';
21
+ import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
22
+ import {
23
+ containerGitState,
24
+ containerNamed,
25
+ gitBlockersForRemoval,
26
+ investigationBlocker,
27
+ runningBindMounts,
28
+ } from './docker/containers.js';
29
+ import { imageNamed } from './docker/images.js';
30
+ import { resolveEnvironment } from './env/resolve.js';
31
+ import { GitInspector } from './git.js';
32
+ import { HealthWatch } from './health.js';
33
+ import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
34
+ import { LivenessProbe } from './liveness.js';
35
+ import { resolveOptions } from './options.js';
36
+ import { isWithin } from './paths.js';
37
+ import { OTHER_RULES } from './rules/other.js';
38
+ import { compareVersions } from './rules/versions.js';
39
+ import { analyzeRustProfile } from './scanners/rust.js';
40
+ import { filterItems, revalidateReport } from './scan.js';
41
+
42
+ const STOPPED = new Set(['exited', 'created', 'dead']);
43
+ const COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
44
+ const KIND_ORDER = ['cache', 'leftover', 'build', 'agent-state', 'package'];
45
+
46
+ /**
47
+ * Cleaning order: tier first, then caches and leftovers before project
48
+ * dependencies, Docker objects, and stopped containers last; larger first.
49
+ */
50
+ export function cleanOrder(a, b) {
51
+ const rank = (item) => {
52
+ if (item.kind === 'container') {
53
+ return 4;
54
+ }
55
+ if (item.kind === 'docker') {
56
+ return 3;
57
+ }
58
+ return KIND_ORDER.includes(item.kind) ? 1 : 2;
59
+ };
60
+ return (
61
+ tierRank(a.tier) - tierRank(b.tier) ||
62
+ rank(a) - rank(b) ||
63
+ b.bytes - a.bytes
64
+ );
65
+ }
66
+
67
+ function sumBytes(usages, paths = [...usages.keys()]) {
68
+ let total = 0;
69
+ for (const target of paths) {
70
+ total += usages.get(target)?.bytes ?? 0;
71
+ }
72
+ return total;
73
+ }
74
+
75
+ function entryFor(item) {
76
+ return {
77
+ id: item.id,
78
+ env: item.env,
79
+ envLabel: item.envLabel,
80
+ rule: item.rule,
81
+ tier: item.tier,
82
+ kind: item.kind,
83
+ description: item.description,
84
+ path: item.path,
85
+ paths: [...item.paths],
86
+ pathCount: item.paths.length,
87
+ deletedPaths: [],
88
+ skippedPaths: [],
89
+ action: item.action?.type ?? 'none',
90
+ plannedBytes: item.bytes,
91
+ status: 'skipped',
92
+ reason: null,
93
+ freedBytes: 0,
94
+ durationMs: 0,
95
+ };
96
+ }
97
+
98
+ async function browserRevisionBlocker(env, item) {
99
+ const rule = OTHER_RULES.find((candidate) => candidate.id === item.rule);
100
+ if (!rule?.versionPattern) {
101
+ return null;
102
+ }
103
+ for (const target of item.paths) {
104
+ const parent = env.path.dirname(target);
105
+ const match = rule.versionPattern.exec(env.path.basename(target));
106
+ if (!match) {
107
+ return `cannot verify browser revision ${target}`;
108
+ }
109
+ const siblings = await env.list(parent);
110
+ const newer = siblings.some((entry) => {
111
+ const other = rule.versionPattern.exec(entry.name);
112
+ return (
113
+ entry.type === 'dir' &&
114
+ other &&
115
+ other.groups.family === match.groups.family &&
116
+ compareVersions(other.groups.version, match.groups.version) > 0
117
+ );
118
+ });
119
+ if (!newer) {
120
+ return `${target} is now the newest installed browser revision`;
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ export class Cleaner {
127
+ /**
128
+ * @param {object} report output of `scan()`
129
+ * @param {object} options resolved options plus `confirm`, `dryRun`, `env`
130
+ */
131
+ constructor(report, options) {
132
+ this.report = report;
133
+ this.options = options;
134
+ this.contexts = new Map();
135
+ this.health = new HealthWatch();
136
+ this.descriptors = new Map(
137
+ (report.environments ?? []).map((env) => [env.id, env])
138
+ );
139
+ this.backupRoot = path.join(
140
+ backupDirectory(options),
141
+ new Date(options.now()).toISOString().replace(/[:.]/g, '-')
142
+ );
143
+ }
144
+
145
+ context(envId) {
146
+ if (!this.contexts.has(envId)) {
147
+ const descriptor = this.descriptors.get(envId) ?? {
148
+ id: envId,
149
+ chain: [],
150
+ };
151
+ const { env, executor } = resolveEnvironment(descriptor, this.options);
152
+ this.contexts.set(envId, {
153
+ env,
154
+ executor,
155
+ git: new GitInspector(env),
156
+ liveness: new LivenessProbe(env, {
157
+ staleAgeMs: this.options.staleAgeMs,
158
+ now: this.options.now,
159
+ }),
160
+ });
161
+ }
162
+ return this.contexts.get(envId);
163
+ }
164
+
165
+ /**
166
+ * Processes one item and returns its audit entry.
167
+ */
168
+ async process(item) {
169
+ const started = Date.now();
170
+ const entry = entryFor(item);
171
+ try {
172
+ await this.#process(item, entry);
173
+ } catch (error) {
174
+ entry.status = 'failed';
175
+ entry.reason = error.message;
176
+ }
177
+ entry.durationMs = Date.now() - started;
178
+ return entry;
179
+ }
180
+
181
+ async #process(item, entry) {
182
+ const stopped = this.health.stopReason(item);
183
+ if (stopped) {
184
+ entry.reason = stopped;
185
+ return;
186
+ }
187
+ if (item.blockers.length > 0) {
188
+ entry.reason = item.blockers.join('; ');
189
+ return;
190
+ }
191
+ if (!(await this.#confirmed(item))) {
192
+ entry.reason = `needs ${consentFlag(item)} or an interactive yes`;
193
+ return;
194
+ }
195
+ const ctx = this.context(item.env);
196
+ const fresh = await this.#recheck(ctx, item);
197
+ if (fresh.reason) {
198
+ entry.reason = fresh.reason;
199
+ return;
200
+ }
201
+ entry.skippedPaths = fresh.skipped ?? [];
202
+ entry.plannedBytes = fresh.item.bytes;
203
+ entry.paths = [...fresh.item.paths];
204
+ entry.pathCount = fresh.item.paths.length;
205
+ if (this.options.dryRun) {
206
+ entry.status = 'planned';
207
+ return;
208
+ }
209
+ await this.health.baseline(ctx.env, item);
210
+ await this.#execute(ctx, fresh.item, entry);
211
+ const lost = await this.health.check(ctx.env, item);
212
+ if (lost && !entry.reason) {
213
+ entry.reason = lost;
214
+ }
215
+ }
216
+
217
+ async #confirmed(item) {
218
+ const flag = item.requiresConfirmation;
219
+ if (!flag || this.options[flag] === true) {
220
+ return true;
221
+ }
222
+ return this.options.confirm
223
+ ? Boolean(await this.options.confirm(item))
224
+ : false;
225
+ }
226
+
227
+ async #recheck(ctx, item) {
228
+ const type = item.action?.type;
229
+ if (type === 'docker-rm') {
230
+ const git = await containerGitState(
231
+ new DockerCli(ctx.executor),
232
+ item.action.containerId
233
+ );
234
+ const blockers = gitBlockersForRemoval(
235
+ git,
236
+ item.action.containerId,
237
+ this.options
238
+ );
239
+ return blockers.length > 0 ? { reason: blockers.join('; ') } : { item };
240
+ }
241
+ if (type === 'none' || item.paths.length === 0) {
242
+ return { item };
243
+ }
244
+ let paths = item.paths;
245
+ if (item.recheck?.type === 'rust') {
246
+ const current = await analyzeRustProfile(ctx.env, item.recheck.profile, {
247
+ staleAgeMs: this.options.staleAgeMs,
248
+ now: this.options.now(),
249
+ });
250
+ const part = current[item.recheck.part] ?? current;
251
+ const still = new Set(part.entries.map((entry) => entry.path));
252
+ paths = paths.filter((target) => still.has(target));
253
+ }
254
+ const usages = await ctx.env.usageMany(paths);
255
+ paths = paths.filter((target) => usages.get(target));
256
+ if (paths.length === 0) {
257
+ return { reason: 'nothing left to remove (already gone or rebuilt)' };
258
+ }
259
+ const newest = Math.max(
260
+ 0,
261
+ ...[...usages.values()].map((u) => u?.newestMtimeMs ?? 0)
262
+ );
263
+ const fresh = {
264
+ ...item,
265
+ paths,
266
+ bytes: type === 'remove' ? sumBytes(usages, paths) : item.bytes,
267
+ newestMtimeMs: newest || item.newestMtimeMs,
268
+ action: type === 'remove' ? { ...item.action, paths } : item.action,
269
+ };
270
+ const staticBlocker = await this.#staticBlocker(ctx, fresh);
271
+ if (staticBlocker) {
272
+ return { reason: staticBlocker };
273
+ }
274
+ const safety = await this.#safetyBlocker(ctx, fresh, type);
275
+ return safety
276
+ ? { reason: safety }
277
+ : this.#withoutOpenPaths(ctx, fresh, usages);
278
+ }
279
+
280
+ /**
281
+ * Items checked path by path (`checks.perPath`) lose only the paths a
282
+ * process holds open; the rest stays eligible.
283
+ */
284
+ #withoutOpenPaths(ctx, item, usages) {
285
+ if (!item.checks?.perPath) {
286
+ return { item };
287
+ }
288
+ const open = ctx.liveness.openPathsOf(item.paths);
289
+ const skipped = [...open].map(([target, reason]) => ({
290
+ path: target,
291
+ reason,
292
+ }));
293
+ const paths = item.paths.filter((target) => !open.has(target));
294
+ if (paths.length === 0) {
295
+ return { reason: `busy: every path is in use (${skipped[0].reason})` };
296
+ }
297
+ return {
298
+ item: {
299
+ ...item,
300
+ paths,
301
+ bytes: sumBytes(usages, paths),
302
+ action: { ...item.action, paths },
303
+ },
304
+ skipped,
305
+ };
306
+ }
307
+
308
+ async #staticBlocker(ctx, item) {
309
+ if (
310
+ item.recheck?.type === 'pyenv-envs' &&
311
+ (await ctx.env.list(ctx.env.path.join(item.path, 'envs'))).length > 0
312
+ ) {
313
+ return 'contains pyenv virtual environments';
314
+ }
315
+ if (item.lockfiles) {
316
+ const entries = await ctx.env.list(item.project);
317
+ if (!entries.some((entry) => item.lockfiles.includes(entry.name))) {
318
+ return `no lockfile remains in ${item.project}`;
319
+ }
320
+ }
321
+ return browserRevisionBlocker(ctx.env, item);
322
+ }
323
+
324
+ async #safetyBlocker(ctx, item, type) {
325
+ await ctx.liveness.refresh(true);
326
+ await ctx.liveness.resolve(item.paths);
327
+ const busy = ctx.liveness.busyReason(item);
328
+ if (busy) {
329
+ return `busy: ${busy}`;
330
+ }
331
+ const mount = await this.#bindMountBlocker(ctx, item, type);
332
+ if (mount) {
333
+ return mount;
334
+ }
335
+ return this.#gitBlocker(ctx, item);
336
+ }
337
+
338
+ async #bindMountBlocker(ctx, item, type) {
339
+ if (
340
+ ctx.env.kind === 'host' &&
341
+ type === 'remove' &&
342
+ this.report.docker?.daemons?.some((daemon) => daemon.env === ctx.env.id)
343
+ ) {
344
+ const mounts = await runningBindMounts(new DockerCli(ctx.executor));
345
+ for (const mount of mounts) {
346
+ if (
347
+ item.paths.some(
348
+ (target) =>
349
+ isWithin(target, mount.source, ctx.env.path) ||
350
+ isWithin(mount.source, target, ctx.env.path)
351
+ )
352
+ ) {
353
+ return `bind-mounted by running container ${mount.containerId}`;
354
+ }
355
+ }
356
+ }
357
+ return null;
358
+ }
359
+
360
+ async #gitBlocker(ctx, item) {
361
+ if (item.project || item.rule === 'gh-issue-solver-workdirs') {
362
+ ctx.git.invalidate();
363
+ const blockers =
364
+ item.rule === 'gh-issue-solver-workdirs'
365
+ ? await ctx.git.treeBlockers(item.path)
366
+ : await ctx.git.pathBlockers(item.path, {
367
+ requireClean: item.tier !== 'safe',
368
+ });
369
+ if (blockers.length > 0) {
370
+ return blockers.join('; ');
371
+ }
372
+ }
373
+ return null;
374
+ }
375
+
376
+ async #execute(ctx, item, entry) {
377
+ const { action } = item;
378
+ if (action.type === 'remove') {
379
+ await this.#removePaths(ctx, item, entry);
380
+ } else if (action.type === 'command') {
381
+ await this.#command(ctx, item, entry);
382
+ } else if (action.type === 'docker-rm') {
383
+ await this.#removeContainer(ctx, item, entry);
384
+ } else {
385
+ entry.reason = 'no safe action for this item';
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Removes the paths one by one, re-checking liveness before each. A busy
391
+ * item stops; an item checked path by path skips just the busy path.
392
+ */
393
+ async #removePaths(ctx, item, entry) {
394
+ for (const target of item.action.paths) {
395
+ await ctx.liveness.refresh(true);
396
+ const stopped = this.health.verify(item, ctx.liveness.processList());
397
+ if (stopped) {
398
+ entry.reason = `stopped before ${target}: ${stopped}`;
399
+ return;
400
+ }
401
+ await ctx.liveness.resolve([target]);
402
+ const busy = ctx.liveness.busyReason({ ...item, paths: [target] });
403
+ if (busy) {
404
+ entry.reason = `stopped before ${target}: busy: ${busy}`;
405
+ return;
406
+ }
407
+ const usage = await ctx.env.usage(target);
408
+ const inUse =
409
+ item.checks?.perPath &&
410
+ (ctx.liveness.openInside([target]) ??
411
+ ctx.liveness.recentWrite(usage?.newestMtimeMs));
412
+ if (inUse) {
413
+ entry.skippedPaths.push({ path: target, reason: inUse });
414
+ continue;
415
+ }
416
+ await ctx.env.remove([target]);
417
+ entry.status = 'removed';
418
+ entry.deletedPaths.push(target);
419
+ entry.freedBytes += usage?.bytes ?? 0;
420
+ await this.options.onProgress?.(entry);
421
+ }
422
+ if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
423
+ entry.reason = 'every path was in use or recently written';
424
+ }
425
+ }
426
+
427
+ async #command(ctx, item, entry) {
428
+ const { action } = item;
429
+ const isDocker = action.argv[0] === 'docker';
430
+ if (isDocker) {
431
+ assertAllowed(action.argv.slice(1));
432
+ }
433
+ await ctx.liveness.refresh(true);
434
+ const busy = ctx.liveness.busyReason(item);
435
+ if (busy) {
436
+ entry.reason = `busy: ${busy}`;
437
+ return;
438
+ }
439
+ const measure = action.measure ?? [];
440
+ const before = measure.length > 0 ? await ctx.env.usageMany(measure) : null;
441
+ const result = await ctx.env.run(action.argv, {
442
+ timeoutMs: COMMAND_TIMEOUT_MS,
443
+ });
444
+ entry.command = action.argv.join(' ');
445
+ if (result.code !== 0) {
446
+ entry.status = 'failed';
447
+ entry.reason = `\`${entry.command}\` exited with ${result.code}: ${result.stderr.trim().slice(0, 500)}`;
448
+ return;
449
+ }
450
+ entry.status = 'removed';
451
+ const reclaimed = /Total reclaimed space:\s*(\S+)/.exec(result.stdout);
452
+ if (reclaimed) {
453
+ entry.freedBytes = parseDockerSize(reclaimed[1]);
454
+ } else if (before) {
455
+ const after = await ctx.env.usageMany(measure);
456
+ entry.freedBytes = Math.max(0, sumBytes(before) - sumBytes(after));
457
+ } else {
458
+ entry.freedBytes = item.bytes;
459
+ entry.freedEstimated = true;
460
+ }
461
+ }
462
+
463
+ async #removeContainer(ctx, item, entry) {
464
+ const { containerId, name } = item.action;
465
+ const docker = new DockerCli(ctx.executor);
466
+ const [inspected] = await docker.inspect([containerId]).catch(() => []);
467
+ if (!inspected) {
468
+ entry.reason = 'container no longer exists';
469
+ return;
470
+ }
471
+ const state = inspected.State?.Status;
472
+ if (!STOPPED.has(state)) {
473
+ entry.reason = `container is ${state} now, left alone`;
474
+ return;
475
+ }
476
+ if (
477
+ item.action.finishedAt &&
478
+ inspected.State.FinishedAt !== item.action.finishedAt
479
+ ) {
480
+ entry.reason = 'container ran again since the scan, scan again first';
481
+ return;
482
+ }
483
+ const kept = investigationBlocker(
484
+ inspected.State,
485
+ containerId,
486
+ name,
487
+ this.options
488
+ );
489
+ if (kept) {
490
+ entry.reason = kept;
491
+ return;
492
+ }
493
+ const git = await containerGitState(docker, containerId);
494
+ const blockers = gitBlockersForRemoval(git, containerId, this.options);
495
+ if (blockers.length > 0) {
496
+ entry.reason = blockers.join('; ');
497
+ return;
498
+ }
499
+ entry.backup = await this.#backupContainer(docker, ctx, item, inspected);
500
+ const [beforeRm] = await docker.inspect([containerId]).catch(() => []);
501
+ if (!beforeRm || !STOPPED.has(beforeRm.State?.Status)) {
502
+ entry.reason = 'container state changed while saving logs, left alone';
503
+ return;
504
+ }
505
+ if (beforeRm.State?.FinishedAt !== inspected.State?.FinishedAt) {
506
+ entry.reason = 'container ran again while saving logs, left alone';
507
+ return;
508
+ }
509
+ const removed = await docker.run(['rm', containerId]);
510
+ if (removed.code !== 0) {
511
+ entry.status = 'failed';
512
+ entry.reason = `docker rm ${name} failed: ${removed.stderr.trim()}`;
513
+ return;
514
+ }
515
+ entry.status = 'removed';
516
+ entry.freedBytes = item.bytes;
517
+ }
518
+
519
+ async #backupContainer(docker, ctx, item, inspected) {
520
+ const safe = (value) => value.replace(/[^\w.-]+/g, '_');
521
+ const dir = path.join(
522
+ this.backupRoot,
523
+ safe(ctx.env.id),
524
+ `${safe(item.action.name)}-${item.action.containerId.slice(0, 12)}`
525
+ );
526
+ await fsp.mkdir(dir, { recursive: true });
527
+ await fsp.writeFile(
528
+ path.join(dir, 'inspect.json'),
529
+ `${JSON.stringify(inspected, null, 2)}\n`
530
+ );
531
+ const child = ctx.executor.spawn(
532
+ docker.argv(['logs', '--timestamps', item.action.containerId])
533
+ );
534
+ const stdout = createWriteStream(path.join(dir, 'stdout.log'));
535
+ const stderr = createWriteStream(path.join(dir, 'stderr.log'));
536
+ child.stdout.pipe(stdout);
537
+ child.stderr.pipe(stderr);
538
+ const [[code]] = await Promise.all([
539
+ once(child, 'close'),
540
+ finished(stdout),
541
+ finished(stderr),
542
+ ]);
543
+ if (code !== 0) {
544
+ throw new Error(`saving docker logs failed (exit ${code}), not removed`);
545
+ }
546
+ return dir;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Freed and remaining reclaimable bytes per environment.
552
+ */
553
+ export function environmentTotals(report, entries) {
554
+ const removed = new Set(
555
+ entries.filter((e) => e.status === 'removed').map((e) => e.id)
556
+ );
557
+ const labels = new Map(
558
+ (report.environments ?? []).map((env) => [env.id, env.label])
559
+ );
560
+ const totals = {};
561
+ const bucket = (env, label) => {
562
+ totals[env] ??= {
563
+ label: labels.get(env) ?? label ?? env,
564
+ freedBytes: 0,
565
+ leftBytes: 0,
566
+ removed: 0,
567
+ planned: 0,
568
+ skipped: 0,
569
+ failed: 0,
570
+ };
571
+ return totals[env];
572
+ };
573
+ for (const entry of entries) {
574
+ const env = bucket(entry.env, entry.envLabel);
575
+ env.freedBytes += entry.freedBytes;
576
+ env[entry.status]++;
577
+ }
578
+ const left = dropNested(report.items.filter((item) => !removed.has(item.id)));
579
+ for (const item of left) {
580
+ bucket(item.env, item.envLabel).leftBytes += item.bytes;
581
+ }
582
+ return totals;
583
+ }
584
+
585
+ /**
586
+ * Report items that pass `only` and `exclude`, which also apply to a report
587
+ * scanned earlier with other options.
588
+ */
589
+ export function wantedItems(report, options) {
590
+ const approved = options.approvedIds ? new Set(options.approvedIds) : null;
591
+ return filterItems(report.items, { ...options, minSizeBytes: 0 }, [], path)
592
+ .filter((item) => !approved || approved.has(item.id))
593
+ .map((item) => {
594
+ if (item.action?.type === 'docker-rm') {
595
+ return containerConsent(item, options);
596
+ }
597
+ return item.image && imageNamed(options.removeImages, item.image)
598
+ ? { ...item, requiresConfirmation: null }
599
+ : item;
600
+ });
601
+ }
602
+
603
+ /**
604
+ * Applies per-container consent: `--remove-container` approves the removal
605
+ * of that container and lifts its investigation hold, an exact
606
+ * `--allow-dirty-container` id waives its verified Git work.
607
+ */
608
+ function containerConsent(item, options) {
609
+ const { containerId, name } = item.action;
610
+ const named = containerNamed(options.removeContainers, containerId, name);
611
+ const dirtyAllowed = options.allowDirtyContainers?.includes(containerId);
612
+ if (!named && !dirtyAllowed) {
613
+ return item;
614
+ }
615
+ const lifted = (reason) =>
616
+ (named && reason === item.container?.investigation) ||
617
+ (dirtyAllowed &&
618
+ item.container?.gitBlockers?.includes(reason) &&
619
+ /^Git repository .+ has /.test(reason));
620
+ return {
621
+ ...item,
622
+ requiresConfirmation: named ? null : item.requiresConfirmation,
623
+ blockers: item.blockers.filter((reason) => !lifted(reason)),
624
+ };
625
+ }
626
+
627
+ /**
628
+ * Items `clean()` acts on for the chosen tier, in cleaning order.
629
+ */
630
+ export function planItems(report, options) {
631
+ return selectByTier(wantedItems(report, options), options.tier).sort(
632
+ cleanOrder
633
+ );
634
+ }
635
+
636
+ /** Options that only the cleaning run itself may grant. */
637
+ export const CONFIRMATION_FLAGS = [
638
+ 'removeStoppedContainers',
639
+ 'removeContainers',
640
+ 'removeImages',
641
+ 'includeVolumes',
642
+ 'allowDirtyRepos',
643
+ 'allowDirtyContainers',
644
+ ];
645
+
646
+ /**
647
+ * Scan options of the report overlaid with the clean options. Consent
648
+ * (confirmation flags) is never inherited from the report.
649
+ */
650
+ export function cleanOptions(report, input) {
651
+ const options = resolveOptions(input);
652
+ if (options.tier === 'aggressive') {
653
+ throw new Error('aggressive cleanup is available only in emergency mode');
654
+ }
655
+ return options;
656
+ }
657
+
658
+ /**
659
+ * Cleans the items of `report` up to `options.tier`.
660
+ * @param {object} report output of `scan()`
661
+ * @param {object} [input] options (see ./options.js) plus `dryRun`,
662
+ * `confirm(item) → Promise<boolean>`, `env`, `audit: false`
663
+ * @returns {Promise<object>} audit log
664
+ */
665
+ export async function clean(report, input = {}) {
666
+ report = await revalidateReport(report, input);
667
+ const options = cleanOptions(report, input);
668
+ const audit = startAudit(input.command ?? 'clean', {
669
+ dryRun: Boolean(options.dryRun),
670
+ tier: options.tier,
671
+ report: { createdAt: report.createdAt, totals: report.totals },
672
+ });
673
+ const persistProgress = async (entry) => {
674
+ if (!audit.entries.includes(entry)) {
675
+ audit.entries.push(entry);
676
+ }
677
+ if (options.audit !== false) {
678
+ await writeAudit(audit, { ...options, inProgress: true });
679
+ }
680
+ };
681
+ const cleaner = new Cleaner(report, {
682
+ ...options,
683
+ onProgress: persistProgress,
684
+ });
685
+ audit.health = cleaner.health.records;
686
+ if (options.audit !== false) {
687
+ await writeAudit(audit, { ...options, inProgress: true });
688
+ }
689
+ for (const item of planItems(report, options)) {
690
+ const entry = await cleaner.process(item);
691
+ if (!audit.entries.includes(entry)) {
692
+ audit.entries.push(entry);
693
+ }
694
+ options.onEntry?.(entry, item);
695
+ if (options.audit !== false) {
696
+ await writeAudit(audit, { ...options, inProgress: true });
697
+ }
698
+ }
699
+ return finishAudit(audit, report, options);
700
+ }
701
+
702
+ /**
703
+ * Adds totals and writes the audit log (unless `audit: false`).
704
+ */
705
+ export async function finishAudit(audit, report, options) {
706
+ audit.finishedAt = new Date().toISOString();
707
+ audit.freedBytes = audit.entries.reduce((sum, e) => sum + e.freedBytes, 0);
708
+ audit.plannedBytes = audit.entries
709
+ .filter((e) => e.status === 'planned')
710
+ .reduce((sum, e) => sum + e.plannedBytes, 0);
711
+ audit.environments = environmentTotals(report, audit.entries);
712
+ if (options.audit !== false) {
713
+ await writeAudit(audit, options);
714
+ }
715
+ return audit;
716
+ }