disk-space-saviour 0.0.0-stage → 0.11.30

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/options.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Option defaults and normalization shared by `scan()`, `clean()` and
3
+ * `emergency()`. Sizes and durations accept numbers (bytes, milliseconds)
4
+ * or strings (`512M`, `1h`).
5
+ */
6
+
7
+ import { parseDuration, parsePercent, parseSize } from './units.js';
8
+
9
+ export const DEFAULTS = {
10
+ roots: null,
11
+ maxDepth: 6,
12
+ staleAge: '1h',
13
+ inactive: '30d',
14
+ minSize: '1M',
15
+ host: true,
16
+ docker: null,
17
+ dockerDepth: 3,
18
+ containers: [],
19
+ scanners: ['projects', 'global', 'versions', 'agents', 'system'],
20
+ only: [],
21
+ exclude: [],
22
+ includeVolumes: false,
23
+ removeStoppedContainers: false,
24
+ removeContainers: [],
25
+ removeImages: [],
26
+ allowDirtyRepos: false,
27
+ allowDirtyContainers: [],
28
+ noNative: false,
29
+ journalKeep: '512M',
30
+ tier: 'safe',
31
+ yes: false,
32
+ backupDir: null,
33
+ auditDir: null,
34
+ };
35
+
36
+ function toBytes(value) {
37
+ return typeof value === 'number' ? value : parseSize(value);
38
+ }
39
+
40
+ function toMs(value) {
41
+ return typeof value === 'number' ? value : parseDuration(value);
42
+ }
43
+
44
+ function toList(value) {
45
+ if (value === null || value === undefined || value === '') {
46
+ return [];
47
+ }
48
+ return (Array.isArray(value) ? value : [value])
49
+ .flatMap((entry) => String(entry).split(','))
50
+ .map((entry) => entry.trim())
51
+ .filter(Boolean);
52
+ }
53
+
54
+ /**
55
+ * Normalizes user options. `olderThan` is an alias of `staleAge`.
56
+ * @param {object} [input]
57
+ * @returns {object}
58
+ */
59
+ export function resolveOptions(input = {}) {
60
+ if (input.removeUnusedImages) {
61
+ throw new Error(
62
+ '--remove-unused-images is not supported: approve each image with --remove-image REF'
63
+ );
64
+ }
65
+ const merged = { ...DEFAULTS };
66
+ for (const [key, value] of Object.entries(input)) {
67
+ if (value !== undefined) {
68
+ merged[key] = value;
69
+ }
70
+ }
71
+ const staleAge = input.olderThan ?? merged.staleAge;
72
+ const roots = toList(merged.roots);
73
+ return {
74
+ ...merged,
75
+ roots: roots.length > 0 ? roots : null,
76
+ maxDepth: Number(merged.maxDepth),
77
+ dockerDepth: Number(merged.dockerDepth),
78
+ staleAgeMs: toMs(staleAge),
79
+ inactiveMs: toMs(merged.inactive),
80
+ minSizeBytes: toBytes(merged.minSize),
81
+ journalKeepBytes: toBytes(merged.journalKeep),
82
+ containerFilter: toList(merged.containers),
83
+ allowDirtyContainers: toList(merged.allowDirtyContainers),
84
+ removeContainers: toList(merged.removeContainers),
85
+ removeImages: toList(merged.removeImages),
86
+ scanners: toList(merged.scanners),
87
+ only: toList(merged.only),
88
+ exclude: toList(merged.exclude),
89
+ now: merged.now ?? (() => Date.now()),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Parses an emergency goal: `free` (bytes to free, `20G`) and/or `until`
95
+ * (target used percentage, `80%`).
96
+ * @returns {{freeBytes: number|null, untilPercent: number|null}}
97
+ */
98
+ export function resolveGoal({ free, until } = {}) {
99
+ const goal = {
100
+ freeBytes: free === undefined || free === null ? null : toBytes(free),
101
+ untilPercent:
102
+ until === undefined || until === null
103
+ ? null
104
+ : typeof until === 'number'
105
+ ? until
106
+ : parsePercent(until),
107
+ };
108
+ if (goal.freeBytes === null && goal.untilPercent === null) {
109
+ throw new Error('emergency needs a goal: --free <size> or --until <N%>');
110
+ }
111
+ return goal;
112
+ }
package/src/paths.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Path helpers shared by the local and remote (container) environments.
3
+ */
4
+
5
+ const globCache = new Map();
6
+
7
+ /**
8
+ * Converts a basename glob (`*`, `?`, `[...]`) to a RegExp.
9
+ * @param {string} glob
10
+ * @returns {RegExp}
11
+ */
12
+ export function globToRegExp(glob) {
13
+ let cached = globCache.get(glob);
14
+ if (!cached) {
15
+ let source = '';
16
+ for (const char of glob) {
17
+ if (char === '*') {
18
+ source += '[^/\\\\]*';
19
+ } else if (char === '?') {
20
+ source += '[^/\\\\]';
21
+ } else if (char === '[' || char === ']') {
22
+ source += char;
23
+ } else {
24
+ source += char.replace(/[.+^${}()|\\]/g, '\\$&');
25
+ }
26
+ }
27
+ cached = new RegExp(`^${source}$`);
28
+ globCache.set(glob, cached);
29
+ }
30
+ return cached;
31
+ }
32
+
33
+ /**
34
+ * @param {string} name
35
+ * @param {string} glob
36
+ */
37
+ export function matchesGlob(name, glob) {
38
+ return globToRegExp(glob).test(name);
39
+ }
40
+
41
+ /**
42
+ * Whether `child` equals `parent` or lies below it.
43
+ * @param {string} child
44
+ * @param {string} parent
45
+ * @param {{sep: string}} pathApi node:path flavour used by the environment
46
+ */
47
+ export function isWithin(child, parent, pathApi) {
48
+ if (!child || !parent) {
49
+ return false;
50
+ }
51
+ const normalize = (value) =>
52
+ pathApi.sep === '\\' ? value.toLowerCase() : value;
53
+ const c = normalize(pathApi.resolve(child));
54
+ const p = normalize(pathApi.resolve(parent));
55
+ if (c === p) {
56
+ return true;
57
+ }
58
+ const prefix = p.endsWith(pathApi.sep) ? p : p + pathApi.sep;
59
+ return c.startsWith(prefix);
60
+ }
61
+
62
+ /**
63
+ * Expands `~` and `{VAR}` tokens of a rule path for one home directory.
64
+ * Returns null when a referenced variable is not set.
65
+ * @param {string} pattern
66
+ * @param {{home: string, vars?: Record<string,string|undefined>, pathApi: object}} context
67
+ */
68
+ export function expandRulePath(pattern, { home, vars = {}, pathApi }) {
69
+ let missing = false;
70
+ let expanded = pattern.replace(/\{([A-Z_]+)\}/g, (_, name) => {
71
+ const value = vars[name];
72
+ if (!value) {
73
+ missing = true;
74
+ return '';
75
+ }
76
+ return value;
77
+ });
78
+ if (missing) {
79
+ return null;
80
+ }
81
+ if (expanded === '~') {
82
+ expanded = home;
83
+ } else if (expanded.startsWith('~/')) {
84
+ expanded = pathApi.join(home, expanded.slice(2));
85
+ }
86
+ return pathApi.normalize(expanded);
87
+ }
88
+
89
+ /**
90
+ * Expands glob segments (`*`) in an absolute path by listing directories.
91
+ * @param {object} env environment adapter
92
+ * @param {string} pattern absolute path that may contain glob segments
93
+ * @returns {Promise<string[]>}
94
+ */
95
+ export async function expandGlobPath(env, pattern) {
96
+ return (await expandGlobPaths(env, [pattern])).get(pattern) ?? [];
97
+ }
98
+
99
+ const GLOB_CHARS = /[*?[]/;
100
+
101
+ async function listDirs(env, dirs) {
102
+ if (typeof env.listMany === 'function') {
103
+ return env.listMany(dirs);
104
+ }
105
+ const listings = await Promise.all(dirs.map((dir) => env.list(dir)));
106
+ return new Map(dirs.map((dir, index) => [dir, listings[index]]));
107
+ }
108
+
109
+ /**
110
+ * Paths among `targets` that exist, checked in one batch when the
111
+ * environment supports it.
112
+ * @returns {Promise<Set<string>>}
113
+ */
114
+ export async function existingPaths(env, targets) {
115
+ if (typeof env.existsMany === 'function') {
116
+ return env.existsMany(targets);
117
+ }
118
+ const flags = await Promise.all(targets.map((target) => env.exists(target)));
119
+ return new Set(targets.filter((_, index) => flags[index]));
120
+ }
121
+
122
+ function globState(pathApi, pattern) {
123
+ if (!GLOB_CHARS.test(pattern)) {
124
+ return { pattern, segments: [], current: [pattern] };
125
+ }
126
+ const root = pathApi.parse(pattern).root;
127
+ const segments = pattern
128
+ .slice(root.length)
129
+ .split(/[\\/]+/)
130
+ .filter(Boolean);
131
+ return { pattern, segments, current: [root] };
132
+ }
133
+
134
+ function advance(state, segment, listings, pathApi) {
135
+ if (!GLOB_CHARS.test(segment)) {
136
+ return state.current.map((base) => pathApi.join(base, segment));
137
+ }
138
+ return state.current.flatMap((base) =>
139
+ (listings.get(base) ?? [])
140
+ .filter((entry) => matchesGlob(entry.name, segment))
141
+ .map((entry) => pathApi.join(base, entry.name))
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Expands many absolute path patterns (glob segments allowed) with one
147
+ * listing round per path depth and one existence check.
148
+ * @param {object} env environment adapter
149
+ * @param {string[]} patterns
150
+ * @returns {Promise<Map<string, string[]>>} existing matches per pattern
151
+ */
152
+ export async function expandGlobPaths(env, patterns) {
153
+ const pathApi = env.path;
154
+ const states = [...new Set(patterns)].map((p) => globState(pathApi, p));
155
+ for (let step = 0; ; step++) {
156
+ const active = states.filter(
157
+ (state) => step < state.segments.length && state.current.length > 0
158
+ );
159
+ if (active.length === 0) {
160
+ break;
161
+ }
162
+ const toList = new Set(
163
+ active
164
+ .filter((state) => GLOB_CHARS.test(state.segments[step]))
165
+ .flatMap((state) => state.current)
166
+ );
167
+ const listings =
168
+ toList.size > 0 ? await listDirs(env, [...toList]) : new Map();
169
+ for (const state of active) {
170
+ state.current = advance(state, state.segments[step], listings, pathApi);
171
+ }
172
+ }
173
+ const existing = await existingPaths(env, [
174
+ ...new Set(states.flatMap((state) => state.current)),
175
+ ]);
176
+ return new Map(
177
+ states.map((state) => [
178
+ state.pattern,
179
+ state.current.filter((target) => existing.has(target)),
180
+ ])
181
+ );
182
+ }
package/src/report.js ADDED
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Human-readable rendering of scan reports and audit logs. The JSON forms
3
+ * (`--json`) are the report and audit objects themselves.
4
+ */
5
+
6
+ import { TIERS, consentFlag } from './items.js';
7
+ import { formatBytes } from './units.js';
8
+
9
+ const DEFAULT_LIMIT = 25;
10
+
11
+ function plural(count, noun) {
12
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
13
+ }
14
+
15
+ function manyEnvironments(report) {
16
+ return new Set(report.items.map((item) => item.env)).size > 1;
17
+ }
18
+
19
+ function pad(text, width) {
20
+ return String(text).padStart(width);
21
+ }
22
+
23
+ function percentUsed(disk) {
24
+ const total = disk.used + disk.free;
25
+ return total > 0 ? Math.round((disk.used / total) * 100) : 0;
26
+ }
27
+
28
+ /**
29
+ * One line describing the free space of a volume.
30
+ */
31
+ export function formatDisk(disk, label = '') {
32
+ if (!disk) {
33
+ return `${label}free space unknown`;
34
+ }
35
+ const where = disk.path ? `${disk.path}: ` : '';
36
+ return `${label}${where}${formatBytes(disk.free)} free of ${formatBytes(
37
+ disk.used + disk.free
38
+ )} (${percentUsed(disk)}% used)`;
39
+ }
40
+
41
+ function itemLocation(item) {
42
+ if (item.path) {
43
+ const more = item.paths.length > 1 ? ` (+${item.paths.length - 1})` : '';
44
+ return `${item.path}${more}`;
45
+ }
46
+ return item.description;
47
+ }
48
+
49
+ function itemLine(item, showEnv) {
50
+ const env = showEnv ? `[${item.envLabel}] ` : '';
51
+ const confirm = item.requiresConfirmation
52
+ ? ` (needs ${consentFlag(item)})`
53
+ : '';
54
+ return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}`;
55
+ }
56
+
57
+ function limited(lines, limit) {
58
+ if (lines.length <= limit) {
59
+ return lines;
60
+ }
61
+ return [
62
+ ...lines.slice(0, limit),
63
+ ` … ${lines.length - limit} more (use --verbose or --json)`,
64
+ ];
65
+ }
66
+
67
+ function tierSection(report, tier, options) {
68
+ const items = report.items
69
+ .filter((item) => item.tier === tier && item.blockers.length === 0)
70
+ .sort((a, b) => b.bytes - a.bytes);
71
+ if (items.length === 0) {
72
+ return [];
73
+ }
74
+ const showEnv = manyEnvironments(report);
75
+ const bytes = items.reduce((sum, item) => sum + item.bytes, 0);
76
+ return [
77
+ '',
78
+ `${tier.toUpperCase()} ${plural(items.length, 'item')}, ${formatBytes(bytes)}`,
79
+ ...limited(
80
+ items.map((item) => itemLine(item, showEnv)),
81
+ options.limit
82
+ ),
83
+ ];
84
+ }
85
+
86
+ function blockedSection(report, options) {
87
+ const items = report.items.filter((item) => item.blockers.length > 0);
88
+ if (items.length === 0) {
89
+ return [];
90
+ }
91
+ const lines = [];
92
+ for (const item of items.sort((a, b) => b.bytes - a.bytes)) {
93
+ lines.push(itemLine(item, manyEnvironments(report)));
94
+ for (const blocker of item.blockers) {
95
+ lines.push(` blocked: ${blocker}`);
96
+ }
97
+ }
98
+ return [
99
+ '',
100
+ `BLOCKED ${plural(items.length, 'item')} (kept)`,
101
+ ...limited(lines, options.limit * 2),
102
+ ];
103
+ }
104
+
105
+ function repoSummary(repo) {
106
+ if (repo.error) {
107
+ return `${repo.root}: ${repo.error}`;
108
+ }
109
+ const parts = [];
110
+ if (repo.dirty) {
111
+ parts.push(`${repo.dirty} uncommitted`);
112
+ }
113
+ if (repo.unpushed) {
114
+ parts.push(`${repo.unpushed} unpushed`);
115
+ }
116
+ if (repo.stashes) {
117
+ parts.push(`${repo.stashes} stashed`);
118
+ }
119
+ return `${repo.root}: ${parts.length > 0 ? parts.join(', ') : 'clean and pushed'}`;
120
+ }
121
+
122
+ /** Exit code, OOM flag, end time and task of a stopped container. */
123
+ function endedLine(owner) {
124
+ if (!owner || owner.exitCode === null || owner.exitCode === undefined) {
125
+ return null;
126
+ }
127
+ const parts = [
128
+ `exit ${owner.exitCode}${owner.exitReason ? ` (${owner.exitReason})` : ''}`,
129
+ ];
130
+ if (owner.oomKilled) {
131
+ parts.push('OOM killed');
132
+ }
133
+ if (owner.finishedAt) {
134
+ parts.push(`ended ${owner.finishedAt}`);
135
+ }
136
+ if (owner.taskUrl) {
137
+ parts.push(`task ${owner.taskUrl}`);
138
+ }
139
+ return parts.join(', ');
140
+ }
141
+
142
+ function containerLines(container, stoppedItems) {
143
+ const session = container.owner?.session
144
+ ? `session ${container.owner.session}`
145
+ : 'no session label';
146
+ const scanned = container.scanned ? ', scanned via exec' : '';
147
+ const lines = [
148
+ ` ${container.name} (${container.id.slice(0, 12)}) ${container.state}, ${container.image}, ${formatBytes(container.bytes)} written, ${session}${scanned}`,
149
+ ];
150
+ if (container.note) {
151
+ lines.push(` ${container.note}`);
152
+ }
153
+ const ended = endedLine(container.owner);
154
+ if (ended) {
155
+ lines.push(` ${ended}`);
156
+ }
157
+ const item = stoppedItems.get(container.id);
158
+ if (item?.container?.investigation) {
159
+ lines.push(` ${item.container.investigation}`);
160
+ }
161
+ for (const repo of item?.container?.repos ?? []) {
162
+ lines.push(` git ${repoSummary(repo)}`);
163
+ }
164
+ return lines;
165
+ }
166
+
167
+ function lastUseText(user) {
168
+ if (!user) {
169
+ return 'last use unknown';
170
+ }
171
+ const details = [
172
+ user.session && `session ${user.session}`,
173
+ user.taskUrl && `task ${user.taskUrl}`,
174
+ user.finishedAt && `ended ${user.finishedAt}`,
175
+ ].filter(Boolean);
176
+ const more = details.length > 0 ? ` (${details.join(', ')})` : '';
177
+ return `last used by container ${user.container}${more}`;
178
+ }
179
+
180
+ /** Size, creation date, last user and the flag of an unused image. */
181
+ function imageLine(item) {
182
+ const { image } = item;
183
+ const created = image.createdAt ? `created ${image.createdAt}, ` : '';
184
+ const consent = item.requiresConfirmation
185
+ ? `, needs ${consentFlag(item)}`
186
+ : '';
187
+ return ` image ${image.ref} (${String(image.id)
188
+ .replace(/^sha256:/, '')
189
+ .slice(
190
+ 0,
191
+ 12
192
+ )}) unused, ${formatBytes(image.bytes)}, ${created}${lastUseText(image.lastUsedBy)}${consent}`;
193
+ }
194
+
195
+ function dockerSection(report) {
196
+ const docker = report.docker;
197
+ if (!docker || docker.daemons.length === 0) {
198
+ return [];
199
+ }
200
+ const stoppedItems = new Map(
201
+ report.items
202
+ .filter((item) => item.container)
203
+ .map((item) => [item.container.id, item])
204
+ );
205
+ const lines = ['', 'DOCKER'];
206
+ for (const daemon of docker.daemons) {
207
+ lines.push(
208
+ ` daemon ${daemon.name} (depth ${daemon.depth}, ${daemon.serverVersion}, ${daemon.driver}) via ${daemon.env}`
209
+ );
210
+ }
211
+ for (const container of docker.containers) {
212
+ lines.push(...containerLines(container, stoppedItems));
213
+ }
214
+ for (const item of report.items.filter((entry) => entry.image)) {
215
+ lines.push(imageLine(item));
216
+ }
217
+ for (const hint of docker.hints ?? []) {
218
+ lines.push(
219
+ ` hint ${hint.image}: ${formatBytes(hint.bytes)} layer, ${hint.hint}`
220
+ );
221
+ }
222
+ for (const volume of docker.volumes ?? []) {
223
+ lines.push(
224
+ ` volume ${volume.name}: ${formatBytes(volume.bytes)}, ${volume.note}`
225
+ );
226
+ }
227
+ return lines;
228
+ }
229
+
230
+ function totalsSection(report) {
231
+ const parts = TIERS.map(
232
+ (tier) => `${tier} ${formatBytes(report.totals[tier].bytes)}`
233
+ );
234
+ return [
235
+ '',
236
+ `Reclaimable (cumulative): ${parts.join(' · ')}; blocked ${formatBytes(report.totals.blocked.bytes)}`,
237
+ ];
238
+ }
239
+
240
+ function errorsSection(errors) {
241
+ if (!errors || errors.length === 0) {
242
+ return [];
243
+ }
244
+ return [
245
+ '',
246
+ 'ERRORS',
247
+ ...errors.map(
248
+ (error) => ` ${error.env} ${error.scanner}: ${error.message}`
249
+ ),
250
+ ];
251
+ }
252
+
253
+ /**
254
+ * Renders a scan report.
255
+ * @param {object} report
256
+ * @param {{verbose?: boolean}} [options]
257
+ * @returns {string}
258
+ */
259
+ export function formatReport(report, options = {}) {
260
+ const limit = options.verbose ? Infinity : DEFAULT_LIMIT;
261
+ const host = report.environments[0];
262
+ const lines = [
263
+ `disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
264
+ formatDisk(host.disk),
265
+ ];
266
+ for (const tier of TIERS) {
267
+ lines.push(...tierSection(report, tier, { limit }));
268
+ }
269
+ lines.push(...blockedSection(report, { limit }));
270
+ lines.push(...dockerSection(report));
271
+ lines.push(...errorsSection(report.errors));
272
+ lines.push(...totalsSection(report));
273
+ return lines.join('\n');
274
+ }
275
+
276
+ function entryLine(entry) {
277
+ const bytes =
278
+ entry.status === 'removed' ? entry.freedBytes : entry.plannedBytes;
279
+ const size = entry.status === 'skipped' ? '' : formatBytes(bytes ?? 0);
280
+ const reason = entry.reason ? ` (${entry.reason})` : '';
281
+ const location = entry.path ?? entry.description;
282
+ return ` ${entry.status.padEnd(7)} ${pad(size, 10)} ${entry.rule} ${location}${reason}`;
283
+ }
284
+
285
+ function statusCounts(env) {
286
+ return ['removed', 'planned', 'skipped', 'failed']
287
+ .filter((status) => env[status] > 0)
288
+ .map((status) => `${env[status]} ${status}`)
289
+ .join(', ');
290
+ }
291
+
292
+ function environmentLines(environments) {
293
+ return Object.values(environments ?? {}).map(
294
+ (env) =>
295
+ ` ${env.label}: freed ${formatBytes(env.freedBytes)}, left ${formatBytes(env.leftBytes)} (${statusCounts(env)})`
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Whether the watched long-running processes survived, per environment.
301
+ */
302
+ function healthLines(records) {
303
+ const lines = (records ?? []).map((record) => {
304
+ if (record.stopped) {
305
+ return ` ${record.envLabel}: STOPPED cleaning: ${record.stopped}`;
306
+ }
307
+ if (!record.available) {
308
+ return ` ${record.envLabel}: processes could not be listed`;
309
+ }
310
+ return ` ${record.envLabel}: ${record.watched.length} watched processes still running`;
311
+ });
312
+ return lines.length > 0 ? ['', 'Task health:', ...lines] : [];
313
+ }
314
+
315
+ function emergencyLines(audit) {
316
+ if (audit.command !== 'emergency') {
317
+ return [];
318
+ }
319
+ const goal = [
320
+ audit.goal.freeBytes === null
321
+ ? null
322
+ : `${formatBytes(audit.goal.freeBytes)} free`,
323
+ audit.goal.untilPercent === null
324
+ ? null
325
+ : `at most ${audit.goal.untilPercent}% used`,
326
+ ]
327
+ .filter(Boolean)
328
+ .join(' and ');
329
+ return [
330
+ `goal: ${goal} on ${audit.path}`,
331
+ formatDisk(audit.diskBefore, 'before: '),
332
+ formatDisk(
333
+ audit.diskAfter,
334
+ audit.dryRun ? 'after (estimated): ' : 'after: '
335
+ ),
336
+ `goal ${audit.goalMet ? 'met' : 'NOT met'}, highest tier used: ${audit.reachedTier ?? 'none'}`,
337
+ ];
338
+ }
339
+
340
+ /**
341
+ * Renders the audit log of `clean` or `emergency`.
342
+ * @param {object} audit
343
+ * @param {{verbose?: boolean}} [options]
344
+ */
345
+ export function formatAudit(audit, options = {}) {
346
+ const limit = options.verbose ? Infinity : DEFAULT_LIMIT * 2;
347
+ const mode = audit.dryRun ? 'dry run, nothing was deleted' : 'deleting';
348
+ const shown = options.verbose
349
+ ? audit.entries
350
+ : audit.entries.filter((entry) => entry.status !== 'skipped');
351
+ const hidden = audit.entries.length - shown.length;
352
+ const lines = [
353
+ `disk-space-saviour ${audit.command} (${mode})`,
354
+ ...emergencyLines(audit),
355
+ '',
356
+ ...limited(shown.map(entryLine), limit),
357
+ ];
358
+ if (hidden > 0) {
359
+ lines.push(` ${hidden} skipped (use --verbose to list them)`);
360
+ }
361
+ lines.push('', 'Per environment:', ...environmentLines(audit.environments));
362
+ lines.push(...healthLines(audit.health));
363
+ lines.push(
364
+ '',
365
+ audit.dryRun
366
+ ? `Would free ${formatBytes(audit.plannedBytes)} (run with --yes to delete)`
367
+ : `Freed ${formatBytes(audit.freedBytes)}`
368
+ );
369
+ if (audit.file) {
370
+ lines.push(`Audit log: ${audit.file}`);
371
+ }
372
+ return lines.join('\n');
373
+ }