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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolchain versions installed by version managers (nvm, pyenv, rbenv,
|
|
3
|
+
* SDKMAN!, rustup, elan, ghcup, opam, swiftly, VS Code Server).
|
|
4
|
+
*
|
|
5
|
+
* Kept: the manager's default and alias targets, versions pinned by any
|
|
6
|
+
* project below the scan roots, the newest install of each group, and
|
|
7
|
+
* anything a running process executes from (checked by the liveness probe).
|
|
8
|
+
* Everything else is reported as `moderate`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
VERSION_RULES,
|
|
13
|
+
compareVersions,
|
|
14
|
+
versionMatches,
|
|
15
|
+
} from '../rules/versions.js';
|
|
16
|
+
import { block, makeItem } from '../items.js';
|
|
17
|
+
import { expandGlobPath, expandRulePath } from '../paths.js';
|
|
18
|
+
import { scanTimeBusy } from './common.js';
|
|
19
|
+
|
|
20
|
+
async function installDirs(env, rule, home) {
|
|
21
|
+
const dirs = [];
|
|
22
|
+
for (const pattern of rule.dirs) {
|
|
23
|
+
const expanded = expandRulePath(pattern, { home, pathApi: env.path });
|
|
24
|
+
for (const candidate of await expandGlobPath(env, expanded)) {
|
|
25
|
+
const name = env.path.basename(candidate);
|
|
26
|
+
if ((rule.exclude ?? []).includes(name)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if ((await env.stat(candidate))?.type !== 'dir') {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
rule.selfMarker &&
|
|
34
|
+
!(await env.exists(env.path.join(candidate, rule.selfMarker)))
|
|
35
|
+
) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
dirs.push(candidate);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return dirs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function aliasRefs(env, aliasDir) {
|
|
45
|
+
const refs = [];
|
|
46
|
+
const pending = [aliasDir];
|
|
47
|
+
while (pending.length > 0) {
|
|
48
|
+
for (const entry of await env.list(pending.shift())) {
|
|
49
|
+
if (entry.type === 'dir') {
|
|
50
|
+
pending.push(entry.path);
|
|
51
|
+
} else {
|
|
52
|
+
refs.push(
|
|
53
|
+
...((await env.readText(entry.path, 4096)) ?? '').split(/\s+/)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return refs.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function linkRefs(target) {
|
|
62
|
+
if (!target) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const refs = [target.split(/[\\/]/).filter(Boolean).pop()];
|
|
66
|
+
const inside = /(?:^|\/)ghc\/([^/]+)\/bin/.exec(target);
|
|
67
|
+
if (inside) {
|
|
68
|
+
refs.push(inside[1]);
|
|
69
|
+
}
|
|
70
|
+
return refs;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Versions a manager itself marks as default, aliased or current.
|
|
75
|
+
* @returns {Promise<string[]>}
|
|
76
|
+
*/
|
|
77
|
+
async function managerRefs(env, rule, home, installs) {
|
|
78
|
+
const expand = (pattern) =>
|
|
79
|
+
expandRulePath(pattern, { home, pathApi: env.path });
|
|
80
|
+
const refs = [];
|
|
81
|
+
for (const source of rule.defaults ?? []) {
|
|
82
|
+
const text = await env.readText(expand(source.path), 65536);
|
|
83
|
+
refs.push(...source.parse(text));
|
|
84
|
+
}
|
|
85
|
+
if (rule.aliasDir) {
|
|
86
|
+
refs.push(...(await aliasRefs(env, expand(rule.aliasDir))));
|
|
87
|
+
}
|
|
88
|
+
for (const link of rule.defaultLinks ?? []) {
|
|
89
|
+
refs.push(...linkRefs(await env.readLink(expand(link))));
|
|
90
|
+
}
|
|
91
|
+
if (rule.currentLink) {
|
|
92
|
+
const parents = new Set(installs.map((dir) => env.path.dirname(dir)));
|
|
93
|
+
for (const parent of parents) {
|
|
94
|
+
const target = await env.readLink(
|
|
95
|
+
env.path.join(parent, rule.currentLink)
|
|
96
|
+
);
|
|
97
|
+
const name = linkRefs(target)[0];
|
|
98
|
+
if (name) {
|
|
99
|
+
refs.push(rule.versionOf(env.path.join(parent, name)));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return refs.filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Versions pinned by project files (`.nvmrc`, `rust-toolchain.toml`, ...)
|
|
108
|
+
* below the roots, per rule id.
|
|
109
|
+
* @returns {Promise<Map<string, string[]>>}
|
|
110
|
+
*/
|
|
111
|
+
export async function projectPins(env, roots, rules, maxDepth) {
|
|
112
|
+
const byName = new Map();
|
|
113
|
+
for (const rule of rules) {
|
|
114
|
+
for (const [name, parse] of Object.entries(rule.projectFiles ?? {})) {
|
|
115
|
+
byName.set(name, [...(byName.get(name) ?? []), { rule, parse }]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const pins = new Map(rules.map((rule) => [rule.id, []]));
|
|
119
|
+
if (byName.size === 0 || roots.length === 0) {
|
|
120
|
+
return pins;
|
|
121
|
+
}
|
|
122
|
+
const files = await env.findFiles(roots, {
|
|
123
|
+
names: [...byName.keys()],
|
|
124
|
+
maxDepth,
|
|
125
|
+
});
|
|
126
|
+
const heads = await env.readHeads(files, 16384);
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
for (const { rule, parse } of byName.get(env.path.basename(file)) ?? []) {
|
|
129
|
+
pins.get(rule.id).push(...parse(heads.get(file)).filter(Boolean));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return pins;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function newestInstalls(env, rule, installs, usages) {
|
|
136
|
+
const groups = new Map();
|
|
137
|
+
for (const dir of installs) {
|
|
138
|
+
const name = rule.versionOf?.(dir) ?? env.path.basename(dir);
|
|
139
|
+
const family =
|
|
140
|
+
/^([a-z][a-z_-]*?)(?=\d|[-=]|$)/i.exec(name)?.[1] ?? 'release';
|
|
141
|
+
const parent = `${env.path.dirname(dir)}\0${family}`;
|
|
142
|
+
groups.set(parent, [...(groups.get(parent) ?? []), dir]);
|
|
143
|
+
}
|
|
144
|
+
const newest = new Set();
|
|
145
|
+
const versionOf = rule.versionOf ?? ((dir) => env.path.basename(dir));
|
|
146
|
+
for (const group of groups.values()) {
|
|
147
|
+
const sorted = [...group].sort((a, b) =>
|
|
148
|
+
rule.newestBy === 'mtime'
|
|
149
|
+
? (usages.get(b)?.newestMtimeMs ?? 0) -
|
|
150
|
+
(usages.get(a)?.newestMtimeMs ?? 0)
|
|
151
|
+
: compareVersions(versionOf(b), versionOf(a))
|
|
152
|
+
);
|
|
153
|
+
newest.add(sorted[0]);
|
|
154
|
+
}
|
|
155
|
+
return newest;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function blockPyenvEnvironments(env, rule, dir, item) {
|
|
159
|
+
if (rule.id !== 'pyenv-python') {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if ((await env.list(env.path.join(dir, 'envs'))).length > 0) {
|
|
163
|
+
block(item, 'contains pyenv virtual environments');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function ruleItems(context, rule, home, pins) {
|
|
168
|
+
const { env } = context;
|
|
169
|
+
const installs = await installDirs(env, rule, home);
|
|
170
|
+
if (installs.length < 2) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
if (rule.protectAll) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const refs = [...(await managerRefs(env, rule, home, installs)), ...pins];
|
|
177
|
+
const usages = await env.usageMany(installs);
|
|
178
|
+
const newest = newestInstalls(env, rule, installs, usages);
|
|
179
|
+
const versionOf = rule.versionOf ?? ((dir) => env.path.basename(dir));
|
|
180
|
+
const items = [];
|
|
181
|
+
for (const dir of installs) {
|
|
182
|
+
const version = versionOf(dir);
|
|
183
|
+
if (newest.has(dir) || refs.some((ref) => versionMatches(version, ref))) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const usage = usages.get(dir);
|
|
187
|
+
const item = makeItem(env, {
|
|
188
|
+
rule: rule.id,
|
|
189
|
+
kind: 'toolchain',
|
|
190
|
+
ecosystem: rule.ecosystem,
|
|
191
|
+
description: `${rule.description} ${version}`,
|
|
192
|
+
path: dir,
|
|
193
|
+
bytes: usage?.bytes ?? 0,
|
|
194
|
+
newestMtimeMs: usage?.newestMtimeMs ?? 0,
|
|
195
|
+
tier: 'moderate',
|
|
196
|
+
reason: `not the ${rule.manager} default, not pinned by a scanned project, not the newest`,
|
|
197
|
+
checks: { busy: rule.busy ?? [], cwd: dir, mtime: true },
|
|
198
|
+
recheck: rule.id === 'pyenv-python' ? { type: 'pyenv-envs' } : null,
|
|
199
|
+
});
|
|
200
|
+
await blockPyenvEnvironments(env, rule, dir, item);
|
|
201
|
+
const busy = scanTimeBusy(context, item);
|
|
202
|
+
if (busy) {
|
|
203
|
+
block(item, `busy: ${busy}`);
|
|
204
|
+
}
|
|
205
|
+
items.push(item);
|
|
206
|
+
}
|
|
207
|
+
return items;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Scans version manager installs.
|
|
212
|
+
* @returns {Promise<object[]>} report items
|
|
213
|
+
*/
|
|
214
|
+
export async function scanVersions(context, { rules = VERSION_RULES } = {}) {
|
|
215
|
+
const { env, options } = context;
|
|
216
|
+
const homes = options.homes ?? (await env.homeDirs());
|
|
217
|
+
const pins = await projectPins(
|
|
218
|
+
env,
|
|
219
|
+
options.roots ?? [],
|
|
220
|
+
rules,
|
|
221
|
+
options.maxDepth ?? 6
|
|
222
|
+
);
|
|
223
|
+
const items = [];
|
|
224
|
+
for (const rule of rules) {
|
|
225
|
+
for (const home of homes) {
|
|
226
|
+
items.push(...(await ruleItems(context, rule, home, pins.get(rule.id))));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return items;
|
|
230
|
+
}
|
package/src/units.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size, duration, and percentage parsing and formatting.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions with no runtime dependencies, so they are safe to import
|
|
5
|
+
* from browsers (the universal example app uses them) as well as from
|
|
6
|
+
* Node.js, Bun, and Deno.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SIZE_UNITS = {
|
|
10
|
+
'': 1,
|
|
11
|
+
b: 1,
|
|
12
|
+
k: 1024,
|
|
13
|
+
kb: 1024,
|
|
14
|
+
kib: 1024,
|
|
15
|
+
m: 1024 ** 2,
|
|
16
|
+
mb: 1024 ** 2,
|
|
17
|
+
mib: 1024 ** 2,
|
|
18
|
+
g: 1024 ** 3,
|
|
19
|
+
gb: 1024 ** 3,
|
|
20
|
+
gib: 1024 ** 3,
|
|
21
|
+
t: 1024 ** 4,
|
|
22
|
+
tb: 1024 ** 4,
|
|
23
|
+
tib: 1024 ** 4,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DURATION_UNITS = {
|
|
27
|
+
ms: 1,
|
|
28
|
+
s: 1000,
|
|
29
|
+
m: 60 * 1000,
|
|
30
|
+
min: 60 * 1000,
|
|
31
|
+
h: 60 * 60 * 1000,
|
|
32
|
+
d: 24 * 60 * 60 * 1000,
|
|
33
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses a human size such as `20G`, `512MiB`, `1.5 TB` or `1024` (bytes).
|
|
38
|
+
* Binary multiples are used for every suffix, matching `du -h` and `df -h`.
|
|
39
|
+
* @param {string|number} value
|
|
40
|
+
* @returns {number} bytes
|
|
41
|
+
*/
|
|
42
|
+
export function parseSize(value) {
|
|
43
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
44
|
+
return Math.round(value);
|
|
45
|
+
}
|
|
46
|
+
const match = /^(\d+(?:\.\d+)?)\s*([a-z]*)$/i.exec(String(value).trim());
|
|
47
|
+
const unit = match && SIZE_UNITS[match[2].toLowerCase()];
|
|
48
|
+
if (!match || unit === undefined) {
|
|
49
|
+
throw new Error(`Invalid size: ${value} (expected e.g. 20G, 512M, 1024)`);
|
|
50
|
+
}
|
|
51
|
+
return Math.round(Number(match[1]) * unit);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parses a duration such as `1h`, `30m`, `7d`, `90s` or `0`.
|
|
56
|
+
* A bare number is interpreted as seconds.
|
|
57
|
+
* @param {string|number} value
|
|
58
|
+
* @returns {number} milliseconds
|
|
59
|
+
*/
|
|
60
|
+
export function parseDuration(value) {
|
|
61
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
62
|
+
return value * 1000;
|
|
63
|
+
}
|
|
64
|
+
const match = /^(\d+(?:\.\d+)?)\s*([a-z]*)$/i.exec(String(value).trim());
|
|
65
|
+
const unitName = match ? match[2].toLowerCase() || 's' : '';
|
|
66
|
+
const unit = DURATION_UNITS[unitName];
|
|
67
|
+
if (!match || unit === undefined) {
|
|
68
|
+
throw new Error(`Invalid duration: ${value} (expected e.g. 1h, 30m, 7d)`);
|
|
69
|
+
}
|
|
70
|
+
return Math.round(Number(match[1]) * unit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses a percentage such as `80%` or `80`.
|
|
75
|
+
* @param {string|number} value
|
|
76
|
+
* @returns {number} percentage in the range 0..100
|
|
77
|
+
*/
|
|
78
|
+
export function parsePercent(value) {
|
|
79
|
+
const match = /^(\d+(?:\.\d+)?)\s*%?$/.exec(String(value).trim());
|
|
80
|
+
const percent = match ? Number(match[1]) : NaN;
|
|
81
|
+
if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
|
|
82
|
+
throw new Error(`Invalid percentage: ${value} (expected e.g. 80%)`);
|
|
83
|
+
}
|
|
84
|
+
return percent;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Formats bytes using binary multiples, e.g. `27.7 GiB`.
|
|
89
|
+
* @param {number} bytes
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function formatBytes(bytes) {
|
|
93
|
+
if (!Number.isFinite(bytes)) {
|
|
94
|
+
return 'unknown';
|
|
95
|
+
}
|
|
96
|
+
const sign = bytes < 0 ? '-' : '';
|
|
97
|
+
let value = Math.abs(bytes);
|
|
98
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
|
99
|
+
let index = 0;
|
|
100
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
101
|
+
value /= 1024;
|
|
102
|
+
index++;
|
|
103
|
+
}
|
|
104
|
+
const digits = index === 0 || value >= 100 ? 0 : 1;
|
|
105
|
+
return `${sign}${value.toFixed(digits)} ${units[index]}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Formats a duration in milliseconds as a compact human string.
|
|
110
|
+
* @param {number} ms
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function formatDuration(ms) {
|
|
114
|
+
if (!Number.isFinite(ms)) {
|
|
115
|
+
return 'unknown';
|
|
116
|
+
}
|
|
117
|
+
const abs = Math.abs(ms);
|
|
118
|
+
const steps = [
|
|
119
|
+
['d', DURATION_UNITS.d],
|
|
120
|
+
['h', DURATION_UNITS.h],
|
|
121
|
+
['m', DURATION_UNITS.m],
|
|
122
|
+
['s', DURATION_UNITS.s],
|
|
123
|
+
];
|
|
124
|
+
for (const [label, size] of steps) {
|
|
125
|
+
if (abs >= size) {
|
|
126
|
+
return `${Math.floor(abs / size)}${label}`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return `${Math.round(abs)}ms`;
|
|
130
|
+
}
|