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/CHANGELOG.md +587 -0
- package/LICENSE +24 -0
- package/README.md +538 -2
- package/bin/dss.js +5 -0
- package/package.json +96 -4
- package/src/audit.js +84 -0
- package/src/clean.js +716 -0
- package/src/cli.js +448 -0
- package/src/docker/cli.js +289 -0
- package/src/docker/containers.js +334 -0
- package/src/docker/images.js +122 -0
- package/src/docker/scan.js +531 -0
- package/src/emergency.js +169 -0
- package/src/env/local.js +715 -0
- package/src/env/resolve.js +36 -0
- package/src/env/shell.js +433 -0
- package/src/exec.js +185 -0
- package/src/git.js +338 -0
- package/src/health.js +174 -0
- package/src/index.d.ts +255 -0
- package/src/index.js +52 -0
- package/src/items.js +166 -0
- package/src/liveness.js +190 -0
- package/src/options.js +112 -0
- package/src/paths.js +182 -0
- package/src/report.js +376 -0
- package/src/rules/ecosystems.js +916 -0
- package/src/rules/other.js +375 -0
- package/src/rules/versions.js +256 -0
- package/src/scan.js +384 -0
- package/src/scanners/agents.js +166 -0
- package/src/scanners/common.js +38 -0
- package/src/scanners/global.js +217 -0
- package/src/scanners/projects.js +335 -0
- package/src/scanners/rust.js +404 -0
- package/src/scanners/system.js +171 -0
- package/src/scanners/versions.js +230 -0
- package/src/units.js +130 -0
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
|
+
}
|