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/cli.js ADDED
@@ -0,0 +1,448 @@
1
+ /**
2
+ * `dss` command line: scan, clean, emergency and docker subcommands.
3
+ *
4
+ * Deleting needs `--yes`, or an interactive yes on a terminal; without
5
+ * either, `clean` and `emergency` print what they would do (dry run).
6
+ * Every run writes a JSON audit log.
7
+ */
8
+
9
+ import { promises as fsp, readFileSync } from 'node:fs';
10
+ import { createInterface } from 'node:readline/promises';
11
+ import { URL } from 'node:url';
12
+ import { parseArgs } from 'node:util';
13
+
14
+ import { startAudit, writeAudit } from './audit.js';
15
+ import { clean } from './clean.js';
16
+ import { emergency } from './emergency.js';
17
+ import { setTrace } from './exec.js';
18
+ import { resolveGoal } from './options.js';
19
+ import { formatAudit, formatReport } from './report.js';
20
+ import { scan } from './scan.js';
21
+ import { formatBytes } from './units.js';
22
+
23
+ export const USAGE = `Usage: dss <command> [paths...] [options]
24
+
25
+ Commands:
26
+ scan [paths...] Report reclaimable space (never deletes)
27
+ clean [paths...] Delete what the chosen tier allows
28
+ emergency Escalate tiers until a free-space goal is met
29
+ docker scan|clean Only Docker: daemons, containers, nested daemons
30
+ help, version
31
+
32
+ Scope:
33
+ --tier safe|moderate|aggressive clean up to this tier (clean: safe or
34
+ moderate; emergency: aggressive)
35
+ --docker / --no-docker require / skip Docker (default: when reachable)
36
+ --depth N Docker nesting depth to recurse into (default 3)
37
+ --recursive docker: recurse into nested daemons
38
+ --container ID|NAME limit Docker work to these containers (repeat)
39
+ --max-depth N directory depth for project search (default 6)
40
+ --only NAME only these ecosystems, rules or kinds (repeat)
41
+ --exclude PATH|GLOB never touch matching paths (repeat)
42
+ --scanner NAME projects, global, versions, agents, system
43
+ --min-size SIZE ignore smaller items (default 1M)
44
+ --older-than AGE activity window, newer files are kept (1h)
45
+ --inactive AGE project inactivity for moderate tier (30d)
46
+ --journal-keep SIZE journald size to keep (512M)
47
+
48
+ Goals (emergency):
49
+ --free SIZE at least SIZE available, e.g. 20G
50
+ --until N% at most N% used, e.g. 80%
51
+ --path PATH volume to watch (default /)
52
+
53
+ Consent:
54
+ -y, --yes delete without asking (non-interactive use)
55
+ --dry-run never delete, show the plan
56
+ --remove-stopped-containers allow docker rm of verified stopped containers
57
+ --remove-container ID|NAME remove this stopped container, even one kept
58
+ for investigation (repeat)
59
+ --remove-image REF remove this unused image (repo:tag or id;
60
+ repeat), never implied by a tier
61
+ --include-volumes list unattached volumes for manual review
62
+ --allow-dirty-repos allow dirty host repositories (dangerous)
63
+ --allow-dirty-container ID override verified Git work in this container
64
+
65
+ Output:
66
+ --json print the report or audit log as JSON
67
+ --report FILE clean: use a report saved by scan --json
68
+ --audit-dir DIR audit logs ($DSS_AUDIT_DIR)
69
+ --backup-dir DIR container log backups ($DSS_BACKUP_DIR)
70
+ --verbose list everything and trace commands (DSS_DEBUG=1)
71
+ -h, --help, --version`;
72
+
73
+ const OPTIONS = {
74
+ json: { type: 'boolean' },
75
+ verbose: { type: 'boolean' },
76
+ docker: { type: 'boolean' },
77
+ 'no-docker': { type: 'boolean' },
78
+ depth: { type: 'string' },
79
+ recursive: { type: 'boolean' },
80
+ container: { type: 'string', multiple: true },
81
+ 'max-depth': { type: 'string' },
82
+ only: { type: 'string', multiple: true },
83
+ exclude: { type: 'string', multiple: true },
84
+ scanner: { type: 'string', multiple: true },
85
+ 'min-size': { type: 'string' },
86
+ 'older-than': { type: 'string' },
87
+ 'stale-age': { type: 'string' },
88
+ inactive: { type: 'string' },
89
+ 'journal-keep': { type: 'string' },
90
+ tier: { type: 'string' },
91
+ free: { type: 'string' },
92
+ until: { type: 'string' },
93
+ path: { type: 'string' },
94
+ yes: { type: 'boolean', short: 'y' },
95
+ 'dry-run': { type: 'boolean' },
96
+ 'remove-stopped-containers': { type: 'boolean' },
97
+ 'remove-container': { type: 'string', multiple: true },
98
+ 'remove-image': { type: 'string', multiple: true },
99
+ 'remove-unused-images': { type: 'boolean' },
100
+ 'include-volumes': { type: 'boolean' },
101
+ 'allow-dirty-repos': { type: 'boolean' },
102
+ 'allow-dirty-container': { type: 'string', multiple: true },
103
+ 'no-native': { type: 'boolean' },
104
+ report: { type: 'string' },
105
+ 'audit-dir': { type: 'string' },
106
+ 'backup-dir': { type: 'string' },
107
+ help: { type: 'boolean', short: 'h' },
108
+ version: { type: 'boolean' },
109
+ };
110
+
111
+ const TIER_NAMES = new Set(['safe', 'moderate', 'aggressive']);
112
+
113
+ class UsageError extends Error {}
114
+
115
+ function readVersion() {
116
+ const packageUrl = new URL('../package.json', import.meta.url);
117
+ return JSON.parse(readFileSync(packageUrl, 'utf8')).version;
118
+ }
119
+
120
+ function dockerMode(values) {
121
+ if (values['no-docker']) {
122
+ return false;
123
+ }
124
+ return values.docker ? true : null;
125
+ }
126
+
127
+ function integer(value, flag) {
128
+ if (value === undefined) {
129
+ return undefined;
130
+ }
131
+ const parsed = Number(value);
132
+ if (!Number.isInteger(parsed) || parsed < 0) {
133
+ throw new UsageError(`${flag} must be a non-negative integer`);
134
+ }
135
+ return parsed;
136
+ }
137
+
138
+ function tierOf(values) {
139
+ if (values.tier !== undefined && !TIER_NAMES.has(values.tier)) {
140
+ throw new UsageError(
141
+ `--tier must be safe, moderate or aggressive, got ${values.tier}`
142
+ );
143
+ }
144
+ return values.tier;
145
+ }
146
+
147
+ /**
148
+ * Maps parsed flags to library options.
149
+ * @param {object} values parseArgs values
150
+ * @param {string[]} paths positional roots
151
+ */
152
+ export function toOptions(values, paths = []) {
153
+ return {
154
+ roots: paths.length > 0 ? paths : null,
155
+ docker: dockerMode(values),
156
+ dockerDepth: integer(values.depth, '--depth'),
157
+ containers: values.container,
158
+ maxDepth: integer(values['max-depth'], '--max-depth'),
159
+ only: values.only,
160
+ exclude: values.exclude,
161
+ scanners: values.scanner,
162
+ minSize: values['min-size'],
163
+ staleAge: values['older-than'] ?? values['stale-age'],
164
+ inactive: values.inactive,
165
+ journalKeep: values['journal-keep'],
166
+ tier: tierOf(values),
167
+ free: values.free,
168
+ until: values.until,
169
+ path: values.path,
170
+ removeStoppedContainers: values['remove-stopped-containers'],
171
+ removeContainers: values['remove-container'],
172
+ removeImages: values['remove-image'],
173
+ includeVolumes: values['include-volumes'],
174
+ allowDirtyRepos: values['allow-dirty-repos'],
175
+ allowDirtyContainers: values['allow-dirty-container'],
176
+ noNative: values['no-native'],
177
+ auditDir: values['audit-dir'],
178
+ backupDir: values['backup-dir'],
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Splits argv into a command, positionals and flag values.
184
+ * `dss docker scan` becomes command `docker-scan`.
185
+ */
186
+ export function parseCli(argv) {
187
+ let parsed;
188
+ try {
189
+ parsed = parseArgs({
190
+ args: argv,
191
+ options: OPTIONS,
192
+ allowPositionals: true,
193
+ strict: true,
194
+ });
195
+ } catch (error) {
196
+ throw new UsageError(error.message);
197
+ }
198
+ const { values, positionals } = parsed;
199
+ if (values['remove-unused-images']) {
200
+ throw new UsageError(
201
+ '--remove-unused-images is not supported: approve each image with --remove-image REF'
202
+ );
203
+ }
204
+ let [command = 'help', ...rest] = positionals;
205
+ if (values.version) {
206
+ command = 'version';
207
+ } else if (values.help) {
208
+ command = 'help';
209
+ }
210
+ if (command === 'docker') {
211
+ const [sub = 'scan', ...paths] = rest;
212
+ if (sub !== 'scan' && sub !== 'clean') {
213
+ throw new UsageError(`Unknown docker command: ${sub}`);
214
+ }
215
+ command = `docker-${sub}`;
216
+ rest = paths;
217
+ }
218
+ return { command, paths: rest, values };
219
+ }
220
+
221
+ function dockerOnly(options, values) {
222
+ const nested = values.recursive ? 3 : 1;
223
+ return {
224
+ ...options,
225
+ host: false,
226
+ docker: true,
227
+ dockerDepth: options.dockerDepth ?? nested,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Summary of a scan for the audit log.
233
+ */
234
+ export function scanAudit(report) {
235
+ return startAudit('scan', {
236
+ dryRun: true,
237
+ report: {
238
+ createdAt: report.createdAt,
239
+ durationMs: report.durationMs,
240
+ totals: report.totals,
241
+ environments: report.environments.map((env) => env.id),
242
+ errors: report.errors,
243
+ },
244
+ entries: report.items.map((item) => ({
245
+ id: item.id,
246
+ env: item.env,
247
+ rule: item.rule,
248
+ tier: item.tier,
249
+ path: item.path,
250
+ bytes: item.bytes,
251
+ status: item.blockers.length > 0 ? 'blocked' : 'found',
252
+ reason: item.blockers.join('; ') || item.reason,
253
+ })),
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Terminal I/O used by the CLI; tests pass their own.
259
+ */
260
+ export function processIo() {
261
+ return {
262
+ stdout: (text) => process.stdout.write(`${text}\n`),
263
+ stderr: (text) => process.stderr.write(`${text}\n`),
264
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
265
+ async ask(question) {
266
+ const rl = createInterface({
267
+ input: process.stdin,
268
+ output: process.stderr,
269
+ });
270
+ try {
271
+ return (await rl.question(question)).trim();
272
+ } finally {
273
+ rl.close();
274
+ }
275
+ },
276
+ };
277
+ }
278
+
279
+ const API = { scan, clean, emergency };
280
+
281
+ class Cli {
282
+ constructor(io, api) {
283
+ this.io = io;
284
+ this.api = api;
285
+ }
286
+
287
+ print(values, json, text) {
288
+ this.io.stdout(values.json ? JSON.stringify(json, null, 2) : text());
289
+ }
290
+
291
+ async scan(options, values) {
292
+ const report = await this.api.scan(options);
293
+ const audit = scanAudit(report);
294
+ if (options.audit !== false) {
295
+ await writeAudit(audit, options);
296
+ }
297
+ this.print(values, report, () =>
298
+ [
299
+ formatReport(report, { verbose: values.verbose }),
300
+ `Audit log: ${audit.file ?? 'not written'}`,
301
+ 'Run `dss clean --tier safe` to see the plan, add --yes to delete.',
302
+ ].join('\n')
303
+ );
304
+ return 0;
305
+ }
306
+
307
+ async loadReport(options, values) {
308
+ if (!values.report) {
309
+ return this.api.scan(options);
310
+ }
311
+ return JSON.parse(await fsp.readFile(values.report, 'utf8'));
312
+ }
313
+
314
+ confirmItem() {
315
+ return async (item) => {
316
+ const owner = item.container?.owner?.session
317
+ ? `, session ${item.container.owner.session}`
318
+ : '';
319
+ const answer = await this.io.ask(
320
+ `Remove ${item.description} (${formatBytes(item.bytes)}${owner})? [y/N] `
321
+ );
322
+ return /^y(es)?$/i.test(answer);
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Decides between deleting and a dry run, asking on a terminal.
328
+ * @param {(extra: object) => Promise<object>} run
329
+ */
330
+ async consent(values, run) {
331
+ if (values['dry-run']) {
332
+ return run({ dryRun: true });
333
+ }
334
+ if (values.yes) {
335
+ return run({ dryRun: false });
336
+ }
337
+ if (!this.io.interactive) {
338
+ this.io.stderr('Dry run: pass --yes to delete non-interactively.');
339
+ return run({ dryRun: true });
340
+ }
341
+ const plan = await run({ dryRun: true, audit: false });
342
+ this.io.stderr(formatAudit(plan, { verbose: values.verbose }));
343
+ const answer = await this.io.ask('Delete the planned items? [y/N] ');
344
+ if (!/^y(es)?$/i.test(answer)) {
345
+ this.io.stderr('Nothing was deleted.');
346
+ return run({ dryRun: true });
347
+ }
348
+ return run({
349
+ dryRun: false,
350
+ confirm: this.confirmItem(),
351
+ approvedIds: plan.entries
352
+ .filter((entry) => entry.status === 'planned')
353
+ .map((entry) => entry.id),
354
+ });
355
+ }
356
+
357
+ async clean(options, values) {
358
+ const report = await this.loadReport(options, values);
359
+ const audit = await this.consent(values, (extra) =>
360
+ this.api.clean(report, { ...options, ...extra })
361
+ );
362
+ this.print(values, audit, () =>
363
+ formatAudit(audit, { verbose: values.verbose })
364
+ );
365
+ return audit.entries.some((entry) => entry.status === 'failed') ? 1 : 0;
366
+ }
367
+
368
+ async emergency(options, values) {
369
+ try {
370
+ resolveGoal(options);
371
+ } catch (error) {
372
+ throw new UsageError(error.message);
373
+ }
374
+ const report = await this.loadReport(options, values);
375
+ const audit = await this.consent(values, (extra) =>
376
+ this.api.emergency({ ...options, report, ...extra })
377
+ );
378
+ this.print(values, audit, () =>
379
+ formatAudit(audit, { verbose: values.verbose })
380
+ );
381
+ if (audit.entries.some((entry) => entry.status === 'failed')) {
382
+ return 1;
383
+ }
384
+ return audit.goalMet ? 0 : 3;
385
+ }
386
+ }
387
+
388
+ function dropUndefined(options) {
389
+ return Object.fromEntries(
390
+ Object.entries(options).filter(([, value]) => value !== undefined)
391
+ );
392
+ }
393
+
394
+ function dispatch(cli, command, options, values) {
395
+ switch (command) {
396
+ case 'scan':
397
+ return cli.scan(options, values);
398
+ case 'clean':
399
+ return cli.clean(options, values);
400
+ case 'emergency':
401
+ return cli.emergency(options, values);
402
+ case 'docker-scan':
403
+ return cli.scan(dockerOnly(options, values), values);
404
+ case 'docker-clean':
405
+ return cli.clean(dockerOnly(options, values), values);
406
+ default:
407
+ throw new UsageError(`Unknown command: ${command}`);
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Runs the CLI.
413
+ * @param {string[]} argv arguments after the program name
414
+ * @param {{io?: object, api?: object}} [deps]
415
+ * @returns {Promise<number>} exit code: 0 ok, 1 error or failed deletion,
416
+ * 2 usage error, 3 emergency goal not met
417
+ */
418
+ export async function runCli(argv, deps = {}) {
419
+ const io = deps.io ?? processIo();
420
+ try {
421
+ const { command, paths, values } = parseCli(argv);
422
+ if (command === 'help') {
423
+ io.stdout(USAGE);
424
+ return 0;
425
+ }
426
+ if (command === 'version') {
427
+ io.stdout(readVersion());
428
+ return 0;
429
+ }
430
+ if (values.verbose) {
431
+ setTrace(true);
432
+ }
433
+ const options = dropUndefined(toOptions(values, paths));
434
+ return await dispatch(
435
+ new Cli(io, deps.api ?? API),
436
+ command,
437
+ options,
438
+ values
439
+ );
440
+ } catch (error) {
441
+ io.stderr(`dss: ${error.message}`);
442
+ if (error instanceof UsageError) {
443
+ io.stderr('Run `dss --help` for usage.');
444
+ return 2;
445
+ }
446
+ return 1;
447
+ }
448
+ }