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/env/local.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base names of a process's executable and `argv[0]`, without duplicates
|
|
3
|
+
* and without the ` (deleted)` suffix of replaced binaries.
|
|
4
|
+
* @param {string} exe target of `/proc/<pid>/exe`
|
|
5
|
+
* @param {string} argv0 first entry of `/proc/<pid>/cmdline`
|
|
6
|
+
* @returns {string[]}
|
|
7
|
+
*/
|
|
8
|
+
export function processAliases(exe, argv0) {
|
|
9
|
+
const names = [exe.replace(/ \(deleted\)$/, ''), argv0.split(' ')[0]]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.map((value) => path.posix.basename(value));
|
|
12
|
+
return [...new Set(names)];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* State letter and start time (clock ticks since boot) of a process from
|
|
17
|
+
* `/proc/<pid>/stat`. Fields are counted after the parenthesized name,
|
|
18
|
+
* which may hold spaces.
|
|
19
|
+
* @param {string} stat
|
|
20
|
+
* @returns {{state: string|null, startTime: string|null}}
|
|
21
|
+
*/
|
|
22
|
+
export function parseProcStat(stat) {
|
|
23
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
24
|
+
return {
|
|
25
|
+
state: fields[0] || null,
|
|
26
|
+
startTime: /^\d+$/.test(fields[19] ?? '') ? fields[19] : null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Working directories by pid from `lsof -F pn -d cwd` field output
|
|
32
|
+
* (`p<pid>` starts a process, `n<path>` names its file).
|
|
33
|
+
* @param {string} output
|
|
34
|
+
* @returns {Map<number, string>}
|
|
35
|
+
*/
|
|
36
|
+
export function parseLsofCwds(output) {
|
|
37
|
+
const cwds = new Map();
|
|
38
|
+
let pid = null;
|
|
39
|
+
for (const line of output.split('\n')) {
|
|
40
|
+
if (line.startsWith('p')) {
|
|
41
|
+
pid = Number(line.slice(1));
|
|
42
|
+
} else if (line.startsWith('n/') && pid !== null) {
|
|
43
|
+
cwds.set(pid, line.slice(1));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return cwds;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Environment adapter for the machine running dss, backed by node:fs.
|
|
51
|
+
*
|
|
52
|
+
* Scanners only talk to environment adapters, so the same rules run on the
|
|
53
|
+
* host and inside containers (see shell.js).
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { promises as fsp } from 'node:fs';
|
|
57
|
+
import os from 'node:os';
|
|
58
|
+
import path from 'node:path';
|
|
59
|
+
|
|
60
|
+
import { hostExecutor } from '../exec.js';
|
|
61
|
+
import { matchesGlob } from '../paths.js';
|
|
62
|
+
|
|
63
|
+
// Directories read concurrently while measuring usage.
|
|
64
|
+
const WALK_BATCH = 64;
|
|
65
|
+
|
|
66
|
+
export const DEFAULT_SKIP_NAMES = [
|
|
67
|
+
'.git',
|
|
68
|
+
'.hg',
|
|
69
|
+
'.svn',
|
|
70
|
+
'.Trash',
|
|
71
|
+
'.cache',
|
|
72
|
+
'.config',
|
|
73
|
+
'.local',
|
|
74
|
+
'.npm',
|
|
75
|
+
'.bun',
|
|
76
|
+
'.yarn',
|
|
77
|
+
'.pnpm-store',
|
|
78
|
+
'.deno',
|
|
79
|
+
'.cargo',
|
|
80
|
+
'.rustup',
|
|
81
|
+
'.m2',
|
|
82
|
+
'.nvm',
|
|
83
|
+
'.pyenv',
|
|
84
|
+
'.rbenv',
|
|
85
|
+
'.sdkman',
|
|
86
|
+
'.elan',
|
|
87
|
+
'.ghcup',
|
|
88
|
+
'.opam',
|
|
89
|
+
'.julia',
|
|
90
|
+
'.gem',
|
|
91
|
+
'.nuget',
|
|
92
|
+
'.pub-cache',
|
|
93
|
+
'.stack',
|
|
94
|
+
'.cabal',
|
|
95
|
+
'.ivy2',
|
|
96
|
+
'.sbt',
|
|
97
|
+
'.hex',
|
|
98
|
+
'.mix',
|
|
99
|
+
'.cpanm',
|
|
100
|
+
'.luarocks',
|
|
101
|
+
'.nimble',
|
|
102
|
+
'.conda',
|
|
103
|
+
'.ccache',
|
|
104
|
+
'.vscode-server',
|
|
105
|
+
'.vscode',
|
|
106
|
+
'.cursor',
|
|
107
|
+
'.cursor-server',
|
|
108
|
+
'.windsurf',
|
|
109
|
+
'.codeium',
|
|
110
|
+
'.qoder',
|
|
111
|
+
'.kiro',
|
|
112
|
+
'.trae',
|
|
113
|
+
'.docker',
|
|
114
|
+
'Library',
|
|
115
|
+
'miniconda3',
|
|
116
|
+
'anaconda3',
|
|
117
|
+
'snap',
|
|
118
|
+
'proc',
|
|
119
|
+
'sys',
|
|
120
|
+
'dev',
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
function entryType(stats) {
|
|
124
|
+
if (stats.isSymbolicLink()) {
|
|
125
|
+
return 'symlink';
|
|
126
|
+
}
|
|
127
|
+
if (stats.isDirectory()) {
|
|
128
|
+
return 'dir';
|
|
129
|
+
}
|
|
130
|
+
if (stats.isFile()) {
|
|
131
|
+
return 'file';
|
|
132
|
+
}
|
|
133
|
+
return 'other';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function diskBytes(stats) {
|
|
137
|
+
return typeof stats.blocks === 'number' && stats.blocks >= 0
|
|
138
|
+
? stats.blocks * 512
|
|
139
|
+
: stats.size;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function safeLstat(target) {
|
|
143
|
+
try {
|
|
144
|
+
return await fsp.lstat(target);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function makeWritable(target) {
|
|
151
|
+
const stats = await safeLstat(target);
|
|
152
|
+
if (!stats || stats.isSymbolicLink()) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
await fsp.chmod(target, stats.mode | 0o700);
|
|
157
|
+
} catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (stats.isDirectory()) {
|
|
161
|
+
let names = [];
|
|
162
|
+
try {
|
|
163
|
+
names = await fsp.readdir(target);
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const name of names) {
|
|
168
|
+
await makeWritable(path.join(target, name));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class LocalEnv {
|
|
174
|
+
/**
|
|
175
|
+
* @param {{homes?: string[], tmpDirs?: string[], executor?: object,
|
|
176
|
+
* platform?: string, vars?: object, skipNames?: string[], id?: string,
|
|
177
|
+
* label?: string}} [options]
|
|
178
|
+
*/
|
|
179
|
+
constructor(options = {}) {
|
|
180
|
+
this.id = options.id ?? 'host';
|
|
181
|
+
this.kind = 'host';
|
|
182
|
+
this.label = options.label ?? `host (${os.hostname()})`;
|
|
183
|
+
this.path = path;
|
|
184
|
+
this.platform = options.platform ?? process.platform;
|
|
185
|
+
this.executor = options.executor ?? hostExecutor();
|
|
186
|
+
this.options = options;
|
|
187
|
+
this.currentHome = os.homedir();
|
|
188
|
+
this.skipNames = new Set(options.skipNames ?? DEFAULT_SKIP_NAMES);
|
|
189
|
+
this.vars = options.vars ?? {
|
|
190
|
+
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
191
|
+
APPDATA: process.env.APPDATA,
|
|
192
|
+
TMPDIR: process.env.TMPDIR,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
homeDirs() {
|
|
197
|
+
return Promise.resolve(this.options.homes ?? [os.homedir()]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async tmpDirs() {
|
|
201
|
+
if (this.options.tmpDirs) {
|
|
202
|
+
return this.options.tmpDirs;
|
|
203
|
+
}
|
|
204
|
+
const dirs = new Set([os.tmpdir()]);
|
|
205
|
+
if (this.platform !== 'win32' && (await this.exists('/tmp'))) {
|
|
206
|
+
dirs.add('/tmp');
|
|
207
|
+
}
|
|
208
|
+
return [...dirs];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async exists(target) {
|
|
212
|
+
return (await safeLstat(target)) !== null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async stat(target) {
|
|
216
|
+
const stats = await safeLstat(target);
|
|
217
|
+
if (!stats) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
type: entryType(stats),
|
|
222
|
+
size: stats.size,
|
|
223
|
+
bytes: diskBytes(stats),
|
|
224
|
+
mtimeMs: stats.mtimeMs,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async readText(target, maxBytes = 1024 * 1024) {
|
|
229
|
+
try {
|
|
230
|
+
const handle = await fsp.open(target, 'r');
|
|
231
|
+
try {
|
|
232
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
233
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
234
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
235
|
+
} finally {
|
|
236
|
+
await handle.close();
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Reads the first bytes of many files in one batch (Rust `.d` files).
|
|
245
|
+
* @param {string[]} files
|
|
246
|
+
* @param {number} maxBytes
|
|
247
|
+
* @returns {Promise<Map<string,string>>}
|
|
248
|
+
*/
|
|
249
|
+
async readHeads(files, maxBytes = 4096) {
|
|
250
|
+
const heads = new Map();
|
|
251
|
+
for (const file of files) {
|
|
252
|
+
const text = await this.readText(file, maxBytes);
|
|
253
|
+
if (text !== null) {
|
|
254
|
+
heads.set(file, text);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return heads;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async list(dir) {
|
|
261
|
+
let names;
|
|
262
|
+
try {
|
|
263
|
+
names = await fsp.readdir(dir);
|
|
264
|
+
} catch {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
const entries = await Promise.all(
|
|
268
|
+
names.map(async (name) => {
|
|
269
|
+
const full = path.join(dir, name);
|
|
270
|
+
const stats = await safeLstat(full);
|
|
271
|
+
if (!stats) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
name,
|
|
276
|
+
path: full,
|
|
277
|
+
type: entryType(stats),
|
|
278
|
+
size: stats.size,
|
|
279
|
+
bytes: diskBytes(stats),
|
|
280
|
+
mtimeMs: stats.mtimeMs,
|
|
281
|
+
};
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
return entries.filter(Boolean);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Disk usage like `du -s`: allocated blocks, each hard-linked inode
|
|
289
|
+
* counted a single time, symlinks not followed. Also reports the newest
|
|
290
|
+
* file mtime.
|
|
291
|
+
*/
|
|
292
|
+
async usage(target) {
|
|
293
|
+
const root = await safeLstat(target);
|
|
294
|
+
if (!root) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const seen = new Set();
|
|
298
|
+
let bytes = 0;
|
|
299
|
+
let apparentBytes = 0;
|
|
300
|
+
let files = 0;
|
|
301
|
+
let errors = 0;
|
|
302
|
+
let newestMtimeMs = 0;
|
|
303
|
+
const account = (stats) => {
|
|
304
|
+
if (stats.nlink > 1 && !stats.isDirectory()) {
|
|
305
|
+
const key = `${stats.dev}:${stats.ino}`;
|
|
306
|
+
if (seen.has(key)) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
seen.add(key);
|
|
310
|
+
}
|
|
311
|
+
bytes += diskBytes(stats);
|
|
312
|
+
apparentBytes += stats.size;
|
|
313
|
+
if (!stats.isDirectory()) {
|
|
314
|
+
files++;
|
|
315
|
+
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
account(root);
|
|
319
|
+
const visit = async (dir) => {
|
|
320
|
+
let names;
|
|
321
|
+
try {
|
|
322
|
+
names = await fsp.readdir(dir);
|
|
323
|
+
} catch {
|
|
324
|
+
errors++;
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
const children = await Promise.all(
|
|
328
|
+
names.map(async (name) => {
|
|
329
|
+
const full = path.join(dir, name);
|
|
330
|
+
return [full, await safeLstat(full)];
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
const subdirs = [];
|
|
334
|
+
for (const [full, stats] of children) {
|
|
335
|
+
if (!stats) {
|
|
336
|
+
errors++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
account(stats);
|
|
340
|
+
if (stats.isDirectory()) {
|
|
341
|
+
subdirs.push(full);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return subdirs;
|
|
345
|
+
};
|
|
346
|
+
let frontier = root.isDirectory() ? [target] : [];
|
|
347
|
+
while (frontier.length > 0) {
|
|
348
|
+
const next = [];
|
|
349
|
+
for (let index = 0; index < frontier.length; index += WALK_BATCH) {
|
|
350
|
+
const found = await Promise.all(
|
|
351
|
+
frontier.slice(index, index + WALK_BATCH).map(visit)
|
|
352
|
+
);
|
|
353
|
+
next.push(...found.flat());
|
|
354
|
+
}
|
|
355
|
+
frontier = next;
|
|
356
|
+
}
|
|
357
|
+
if (files === 0) {
|
|
358
|
+
newestMtimeMs = root.mtimeMs;
|
|
359
|
+
}
|
|
360
|
+
return { bytes, apparentBytes, files, errors, newestMtimeMs };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Finds directories whose basename matches one of `globs`, without
|
|
365
|
+
* descending into matches or into `skipNames`.
|
|
366
|
+
* @returns {Promise<Array<{path: string, name: string, parent: string,
|
|
367
|
+
* siblings: Array<{name: string, type: string, mtimeMs: number}>}>>}
|
|
368
|
+
*/
|
|
369
|
+
async findDirs(roots, { globs, maxDepth = 6, skipNames } = {}) {
|
|
370
|
+
const results = [];
|
|
371
|
+
await this.#walk(roots, maxDepth, skipNames, (dir, entries) => {
|
|
372
|
+
const matched = new Set();
|
|
373
|
+
let siblings = null;
|
|
374
|
+
for (const entry of entries) {
|
|
375
|
+
if (
|
|
376
|
+
entry.type === 'dir' &&
|
|
377
|
+
globs.some((glob) => matchesGlob(entry.name, glob))
|
|
378
|
+
) {
|
|
379
|
+
siblings ??= entries.map(({ name, type, mtimeMs }) => ({
|
|
380
|
+
name,
|
|
381
|
+
type,
|
|
382
|
+
mtimeMs,
|
|
383
|
+
}));
|
|
384
|
+
results.push({
|
|
385
|
+
path: entry.path,
|
|
386
|
+
name: entry.name,
|
|
387
|
+
parent: dir,
|
|
388
|
+
siblings,
|
|
389
|
+
});
|
|
390
|
+
matched.add(entry.name);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return matched;
|
|
394
|
+
});
|
|
395
|
+
return results;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Finds regular files whose basename matches one of `names`.
|
|
400
|
+
* @returns {Promise<string[]>}
|
|
401
|
+
*/
|
|
402
|
+
async findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
|
|
403
|
+
const results = [];
|
|
404
|
+
await this.#walk(roots, maxDepth, skipNames, (_dir, entries) => {
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
if (
|
|
407
|
+
entry.type === 'file' &&
|
|
408
|
+
names.some((glob) => matchesGlob(entry.name, glob))
|
|
409
|
+
) {
|
|
410
|
+
results.push(entry.path);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
});
|
|
415
|
+
return results;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Breadth-first directory walk. `visit(dir, entries)` may return a set of
|
|
420
|
+
* child names that must not be descended into.
|
|
421
|
+
*/
|
|
422
|
+
async #walk(roots, maxDepth, skipNames, visit) {
|
|
423
|
+
const skip = skipNames ? new Set(skipNames) : this.skipNames;
|
|
424
|
+
const visited = new Set();
|
|
425
|
+
const queue = roots.map((root) => [root, 0]);
|
|
426
|
+
while (queue.length > 0) {
|
|
427
|
+
const [dir, depth] = queue.shift();
|
|
428
|
+
if (visited.has(dir)) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
visited.add(dir);
|
|
432
|
+
const entries = await this.list(dir);
|
|
433
|
+
const pruned = visit(dir, entries) ?? new Set();
|
|
434
|
+
if (depth + 1 >= maxDepth) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
for (const entry of entries) {
|
|
438
|
+
if (
|
|
439
|
+
entry.type === 'dir' &&
|
|
440
|
+
!pruned.has(entry.name) &&
|
|
441
|
+
!skip.has(entry.name)
|
|
442
|
+
) {
|
|
443
|
+
queue.push([entry.path, depth + 1]);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async usageMany(targets) {
|
|
450
|
+
const usages = new Map();
|
|
451
|
+
for (const target of targets) {
|
|
452
|
+
usages.set(target, await this.usage(target));
|
|
453
|
+
}
|
|
454
|
+
return usages;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async readLink(target) {
|
|
458
|
+
try {
|
|
459
|
+
return await fsp.readlink(target);
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async realPath(target) {
|
|
466
|
+
try {
|
|
467
|
+
return await fsp.realpath(target);
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
isRoot() {
|
|
474
|
+
return Promise.resolve(process.getuid?.() === 0);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async remove(targets) {
|
|
478
|
+
for (const target of targets) {
|
|
479
|
+
try {
|
|
480
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
// Read-only trees (Go module cache, some toolchains) need write
|
|
486
|
+
// permission on directories before their entries can be unlinked.
|
|
487
|
+
await makeWritable(target);
|
|
488
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async processes() {
|
|
494
|
+
const selfPids = new Set([process.pid, process.ppid]);
|
|
495
|
+
if (this.platform === 'linux') {
|
|
496
|
+
return (await this.#linuxProcesses()).filter(
|
|
497
|
+
(proc) => !selfPids.has(proc.pid)
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (this.platform === 'win32') {
|
|
501
|
+
const result = await this.run(['tasklist', '/fo', 'csv', '/nh']);
|
|
502
|
+
return result.stdout
|
|
503
|
+
.split(/\r?\n/)
|
|
504
|
+
.map((line) => /^"([^"]+)","(\d+)"/.exec(line))
|
|
505
|
+
.filter(Boolean)
|
|
506
|
+
.map((m) => ({ pid: Number(m[2]), name: m[1].replace(/\.exe$/i, '') }))
|
|
507
|
+
.filter((proc) => !selfPids.has(proc.pid));
|
|
508
|
+
}
|
|
509
|
+
const result = await this.run(['ps', '-axo', 'pid=,comm=']);
|
|
510
|
+
const cwds =
|
|
511
|
+
this.platform === 'darwin'
|
|
512
|
+
? parseLsofCwds(
|
|
513
|
+
(
|
|
514
|
+
await this.run(['lsof', '-a', '-d', 'cwd', '-n', '-F', 'pn'], {
|
|
515
|
+
timeoutMs: 30000,
|
|
516
|
+
})
|
|
517
|
+
).stdout
|
|
518
|
+
)
|
|
519
|
+
: new Map();
|
|
520
|
+
return result.stdout
|
|
521
|
+
.split('\n')
|
|
522
|
+
.map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
|
|
523
|
+
.filter(Boolean)
|
|
524
|
+
.map((m) => ({
|
|
525
|
+
pid: Number(m[1]),
|
|
526
|
+
name: path.basename(m[2].trim()),
|
|
527
|
+
cwd: cwds.get(Number(m[1])) ?? null,
|
|
528
|
+
}))
|
|
529
|
+
.filter((proc) => !selfPids.has(proc.pid));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async #linuxProcesses() {
|
|
533
|
+
let pids = [];
|
|
534
|
+
try {
|
|
535
|
+
pids = (await fsp.readdir('/proc')).filter((name) => /^\d+$/.test(name));
|
|
536
|
+
} catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
const processes = [];
|
|
540
|
+
for (const pid of pids) {
|
|
541
|
+
const base = `/proc/${pid}`;
|
|
542
|
+
let name;
|
|
543
|
+
try {
|
|
544
|
+
name = (await fsp.readFile(`${base}/comm`, 'utf8')).trim();
|
|
545
|
+
} catch {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const cwd = await fsp.readlink(`${base}/cwd`).catch(() => null);
|
|
549
|
+
const exe = await fsp.readlink(`${base}/exe`).catch(() => '');
|
|
550
|
+
const cmdline = await fsp
|
|
551
|
+
.readFile(`${base}/cmdline`, 'utf8')
|
|
552
|
+
.catch(() => '');
|
|
553
|
+
const stat = parseProcStat(
|
|
554
|
+
await fsp.readFile(`${base}/stat`, 'utf8').catch(() => '')
|
|
555
|
+
);
|
|
556
|
+
if (stat.state === 'Z') {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
processes.push({
|
|
560
|
+
pid: Number(pid),
|
|
561
|
+
name,
|
|
562
|
+
startTime: stat.startTime,
|
|
563
|
+
cwd,
|
|
564
|
+
aliases: processAliases(exe, cmdline.split('\0')[0]),
|
|
565
|
+
command: cmdline.replaceAll('\0', ' '),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return processes;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Paths currently held open (fds, cwds, executables) by any process.
|
|
573
|
+
* Returns null when the platform gives no reliable answer.
|
|
574
|
+
* @returns {Promise<Set<string>|null>}
|
|
575
|
+
*/
|
|
576
|
+
async openPaths({ strict = true } = {}) {
|
|
577
|
+
if (this.platform === 'linux') {
|
|
578
|
+
const paths = new Set();
|
|
579
|
+
let pids = [];
|
|
580
|
+
try {
|
|
581
|
+
pids = (await fsp.readdir('/proc')).filter((n) => /^\d+$/.test(n));
|
|
582
|
+
} catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
for (const pid of pids) {
|
|
586
|
+
const base = `/proc/${pid}`;
|
|
587
|
+
for (const link of ['cwd', 'exe']) {
|
|
588
|
+
const target = await fsp
|
|
589
|
+
.readlink(`${base}/${link}`)
|
|
590
|
+
.catch((error) => {
|
|
591
|
+
if (
|
|
592
|
+
strict &&
|
|
593
|
+
(error.code === 'EACCES' || error.code === 'EPERM')
|
|
594
|
+
) {
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
return '';
|
|
598
|
+
});
|
|
599
|
+
if (target.startsWith('/')) {
|
|
600
|
+
paths.add(target.replace(/ \(deleted\)$/, ''));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const fds = await fsp.readdir(`${base}/fd`).catch((error) => {
|
|
604
|
+
if (strict && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
return [];
|
|
608
|
+
});
|
|
609
|
+
for (const fd of fds) {
|
|
610
|
+
const target = await fsp
|
|
611
|
+
.readlink(`${base}/fd/${fd}`)
|
|
612
|
+
.catch((error) => {
|
|
613
|
+
if (
|
|
614
|
+
strict &&
|
|
615
|
+
(error.code === 'EACCES' || error.code === 'EPERM')
|
|
616
|
+
) {
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
return '';
|
|
620
|
+
});
|
|
621
|
+
if (target.startsWith('/')) {
|
|
622
|
+
paths.add(target.replace(/ \(deleted\)$/, ''));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return paths;
|
|
627
|
+
}
|
|
628
|
+
if (this.platform === 'darwin') {
|
|
629
|
+
const result = await this.run(['lsof', '-n', '-F', 'n'], {
|
|
630
|
+
timeoutMs: 30000,
|
|
631
|
+
});
|
|
632
|
+
if (result.code !== 0 && !result.stdout) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
return new Set(
|
|
636
|
+
result.stdout
|
|
637
|
+
.split('\n')
|
|
638
|
+
.filter((line) => line.startsWith('n/'))
|
|
639
|
+
.map((line) => line.slice(1))
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
run(argv, options) {
|
|
646
|
+
return this.executor.run(argv, options);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async which(command) {
|
|
650
|
+
const dirs = (process.env.PATH ?? '').split(path.delimiter);
|
|
651
|
+
const extensions =
|
|
652
|
+
this.platform === 'win32'
|
|
653
|
+
? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';')
|
|
654
|
+
: [''];
|
|
655
|
+
for (const dir of dirs.filter(Boolean)) {
|
|
656
|
+
for (const extension of extensions) {
|
|
657
|
+
const stats = await safeLstat(path.join(dir, command + extension));
|
|
658
|
+
if (stats && !stats.isDirectory()) {
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Filesystem capacity of the volume holding `target`.
|
|
668
|
+
* @returns {Promise<{total: number, free: number, used: number}|null>}
|
|
669
|
+
*/
|
|
670
|
+
async diskUsage(target) {
|
|
671
|
+
if (typeof fsp.statfs === 'function') {
|
|
672
|
+
try {
|
|
673
|
+
const stats = await fsp.statfs(target);
|
|
674
|
+
const total = stats.blocks * stats.bsize;
|
|
675
|
+
const free = stats.bavail * stats.bsize;
|
|
676
|
+
const used = total - stats.bfree * stats.bsize;
|
|
677
|
+
return { total, free, used };
|
|
678
|
+
} catch {
|
|
679
|
+
// Fall through to df.
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (this.platform === 'win32') {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
const result = await this.run(['df', '-Pk', target]);
|
|
686
|
+
return parseDf(result.stdout);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async deviceId(target) {
|
|
690
|
+
try {
|
|
691
|
+
return (await fsp.stat(target)).dev;
|
|
692
|
+
} catch {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Parses POSIX `df -Pk` output.
|
|
700
|
+
* @param {string} output
|
|
701
|
+
*/
|
|
702
|
+
export function parseDf(output) {
|
|
703
|
+
const line = output.trim().split('\n').slice(1).join(' ');
|
|
704
|
+
const fields = line.trim().split(/\s+/);
|
|
705
|
+
if (fields.length < 4) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const total = Number(fields[1]) * 1024;
|
|
709
|
+
const used = Number(fields[2]) * 1024;
|
|
710
|
+
const free = Number(fields[3]) * 1024;
|
|
711
|
+
if (![total, used, free].every(Number.isFinite)) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
return { total, free, used };
|
|
715
|
+
}
|