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,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebuilds environment adapters from report descriptors, so a JSON report
|
|
3
|
+
* written by `dss scan --json` can be cleaned later: an empty chain is the
|
|
4
|
+
* host, every chain link is one `docker exec` hop.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { containerExecutor, hostExecutor } from '../exec.js';
|
|
8
|
+
import { LocalEnv } from './local.js';
|
|
9
|
+
import { ShellEnv } from './shell.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{id: string, label?: string, chain?: object[]}} descriptor
|
|
13
|
+
* @param {{env?: object}} [options] `env` overrides the host adapter
|
|
14
|
+
* @returns {{env: object, executor: object}}
|
|
15
|
+
*/
|
|
16
|
+
export function resolveEnvironment(descriptor, options = {}) {
|
|
17
|
+
const chain = descriptor.chain ?? [];
|
|
18
|
+
if (chain.length === 0) {
|
|
19
|
+
const env = options.env ?? new LocalEnv();
|
|
20
|
+
return { env, executor: env.executor ?? hostExecutor() };
|
|
21
|
+
}
|
|
22
|
+
const base = options.env?.executor ?? hostExecutor();
|
|
23
|
+
const executor = chain.reduce(
|
|
24
|
+
(parent, link) =>
|
|
25
|
+
containerExecutor(parent, link.containerId, {
|
|
26
|
+
label: `${parent.label}/${link.name}`,
|
|
27
|
+
}),
|
|
28
|
+
base
|
|
29
|
+
);
|
|
30
|
+
const env = new ShellEnv(executor, {
|
|
31
|
+
id: descriptor.id,
|
|
32
|
+
label: descriptor.label ?? executor.label,
|
|
33
|
+
kind: 'container',
|
|
34
|
+
});
|
|
35
|
+
return { env, executor };
|
|
36
|
+
}
|
package/src/env/shell.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment adapter for a filesystem reached through an executor, usually
|
|
3
|
+
* a running container via `docker exec`.
|
|
4
|
+
*
|
|
5
|
+
* It needs only a POSIX shell with `find`, `stat -c`, `du`, `df`, `head`,
|
|
6
|
+
* `rm`, and `/proc` (GNU coreutils and busybox both qualify), so nothing is
|
|
7
|
+
* installed into the container being inspected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { shellQuote } from '../exec.js';
|
|
13
|
+
import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
|
|
14
|
+
|
|
15
|
+
const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
|
|
16
|
+
const ARG_BATCH = 200;
|
|
17
|
+
const RECORD = '\u0001';
|
|
18
|
+
|
|
19
|
+
const LIST_SCRIPT = `for d in "$@"; do
|
|
20
|
+
printf '${RECORD}%s\\n' "$d"
|
|
21
|
+
cd -- "$d" 2>/dev/null && stat -c '${STAT_FORMAT}' -- * .[!.]* ..?* 2>/dev/null
|
|
22
|
+
done
|
|
23
|
+
true`;
|
|
24
|
+
|
|
25
|
+
const HEADS_SCRIPT = `n="$1"; shift
|
|
26
|
+
for f in "$@"; do
|
|
27
|
+
printf '${RECORD}%s\\n' "$f"
|
|
28
|
+
head -c "$n" -- "$f" 2>/dev/null
|
|
29
|
+
done
|
|
30
|
+
true`;
|
|
31
|
+
|
|
32
|
+
const USAGE_MANY_SCRIPT = `for t in "$@"; do
|
|
33
|
+
[ -e "$t" ] || [ -L "$t" ] || continue
|
|
34
|
+
printf '${RECORD}%s\\n' "$t"
|
|
35
|
+
du -sk -- "$t" 2>/dev/null | cut -f1
|
|
36
|
+
find "$t" ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
|
|
37
|
+
stat -c %Y -- "$t"
|
|
38
|
+
done
|
|
39
|
+
true`;
|
|
40
|
+
|
|
41
|
+
const PROCESS_SCRIPT = `echo "$$"
|
|
42
|
+
for p in /proc/[0-9]*; do
|
|
43
|
+
c=$(cat "$p/comm" 2>/dev/null) || continue
|
|
44
|
+
e=$(readlink "$p/exe" 2>/dev/null)
|
|
45
|
+
a=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null | head -n 1)
|
|
46
|
+
w=$(readlink "$p/cwd" 2>/dev/null)
|
|
47
|
+
t=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1,20)
|
|
48
|
+
printf '%s|%s|%s|%s|%s|%s\\n' "\${p#/proc/}" "$t" "$c" "$e" "$a" "$w"
|
|
49
|
+
done`;
|
|
50
|
+
|
|
51
|
+
const OPEN_PATHS_SCRIPT = `failed=0
|
|
52
|
+
for p in /proc/[0-9]*; do
|
|
53
|
+
[ -d "$p" ] || continue
|
|
54
|
+
ls "$p/fd" >/dev/null 2>&1 || { [ -d "$p" ] && failed=1; continue; }
|
|
55
|
+
for l in "$p/cwd" "$p/exe" "$p"/fd/*; do
|
|
56
|
+
[ -L "$l" ] || continue
|
|
57
|
+
readlink "$l" 2>/dev/null || failed=1
|
|
58
|
+
done
|
|
59
|
+
done
|
|
60
|
+
exit "$failed"`;
|
|
61
|
+
|
|
62
|
+
const REMOVE_SCRIPT = `rm -rf -- "$@" 2>/dev/null && exit 0
|
|
63
|
+
chmod -R u+w -- "$@" 2>/dev/null
|
|
64
|
+
rm -rf -- "$@"`;
|
|
65
|
+
|
|
66
|
+
const EXISTS_SCRIPT = `for p in "$@"; do
|
|
67
|
+
if [ -e "$p" ] || [ -L "$p" ]; then printf '%s\\n' "$p"; fi
|
|
68
|
+
done`;
|
|
69
|
+
|
|
70
|
+
const HOMES_SCRIPT = `echo "$HOME"
|
|
71
|
+
while IFS=: read -r name pw uid gid gecos home shell; do
|
|
72
|
+
case "$uid" in ''|*[!0-9]*) continue ;; esac
|
|
73
|
+
if [ "$uid" -eq 0 ] || { [ "$uid" -ge 1000 ] && [ "$uid" -lt 65534 ]; }; then
|
|
74
|
+
[ -d "$home" ] && [ "$home" != / ] && echo "$home"
|
|
75
|
+
fi
|
|
76
|
+
done < /etc/passwd
|
|
77
|
+
for h in /home/*; do [ -d "$h" ] && echo "$h"; done
|
|
78
|
+
true`;
|
|
79
|
+
|
|
80
|
+
function statType(label) {
|
|
81
|
+
if (label === 'directory') {
|
|
82
|
+
return 'dir';
|
|
83
|
+
}
|
|
84
|
+
if (label.startsWith('regular')) {
|
|
85
|
+
return 'file';
|
|
86
|
+
}
|
|
87
|
+
if (label === 'symbolic link') {
|
|
88
|
+
return 'symlink';
|
|
89
|
+
}
|
|
90
|
+
return 'other';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parses one `stat -c '%F|%s|%b|%B|%Y|%n'` line.
|
|
95
|
+
* @param {string} line
|
|
96
|
+
*/
|
|
97
|
+
export function parseStatLine(line) {
|
|
98
|
+
const parts = line.split('|');
|
|
99
|
+
if (parts.length < 6) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const [type, size, blocks, blockSize, mtime] = parts;
|
|
103
|
+
const name = parts.slice(5).join('|');
|
|
104
|
+
return {
|
|
105
|
+
name,
|
|
106
|
+
type: statType(type),
|
|
107
|
+
size: Number(size),
|
|
108
|
+
bytes: Number(blocks) * Number(blockSize),
|
|
109
|
+
mtimeMs: Number(mtime) * 1000,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function splitRecords(output) {
|
|
114
|
+
const records = new Map();
|
|
115
|
+
for (const chunk of output.split(RECORD).slice(1)) {
|
|
116
|
+
const newline = chunk.indexOf('\n');
|
|
117
|
+
const key = newline === -1 ? chunk : chunk.slice(0, newline);
|
|
118
|
+
records.set(key, newline === -1 ? '' : chunk.slice(newline + 1));
|
|
119
|
+
}
|
|
120
|
+
return records;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseUsage(body) {
|
|
124
|
+
const [kib, counts = '', rootMtime] = body.trim().split('\n');
|
|
125
|
+
const [files, newest] = counts.trim().split(/\s+/).map(Number);
|
|
126
|
+
const bytes = Number(kib) * 1024;
|
|
127
|
+
const newestSeconds = files > 0 ? newest : Number(rootMtime);
|
|
128
|
+
return {
|
|
129
|
+
bytes: Number.isFinite(bytes) ? bytes : 0,
|
|
130
|
+
apparentBytes: Number.isFinite(bytes) ? bytes : 0,
|
|
131
|
+
files: Number.isFinite(files) ? files : 0,
|
|
132
|
+
errors: Number.isFinite(bytes) ? 0 : 1,
|
|
133
|
+
newestMtimeMs: Number.isFinite(newestSeconds) ? newestSeconds * 1000 : 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function batches(items, size = ARG_BATCH) {
|
|
138
|
+
const result = [];
|
|
139
|
+
for (let index = 0; index < items.length; index += size) {
|
|
140
|
+
result.push(items.slice(index, index + size));
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findNameExpression(names) {
|
|
146
|
+
return names.flatMap((name, index) =>
|
|
147
|
+
index === 0 ? ['-name', name] : ['-o', '-name', name]
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class ShellEnv {
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} executor executor reaching the target filesystem
|
|
154
|
+
* @param {{id?: string, label?: string, kind?: string, homes?: string[],
|
|
155
|
+
* skipNames?: string[]}} [options]
|
|
156
|
+
*/
|
|
157
|
+
constructor(executor, options = {}) {
|
|
158
|
+
this.executor = executor;
|
|
159
|
+
this.id = options.id ?? executor.label;
|
|
160
|
+
this.kind = options.kind ?? 'container';
|
|
161
|
+
this.label = options.label ?? executor.label;
|
|
162
|
+
this.path = path.posix;
|
|
163
|
+
this.platform = 'linux';
|
|
164
|
+
this.options = options;
|
|
165
|
+
this.vars = {};
|
|
166
|
+
this.currentHome = null;
|
|
167
|
+
this.skipNames = options.skipNames ?? DEFAULT_SKIP_NAMES;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
sh(script, args = [], options = {}) {
|
|
171
|
+
return this.executor.run(['sh', '-c', script, 'sh', ...args], options);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async homeDirs() {
|
|
175
|
+
if (this.options.homes) {
|
|
176
|
+
return this.options.homes;
|
|
177
|
+
}
|
|
178
|
+
const result = await this.sh(HOMES_SCRIPT);
|
|
179
|
+
const lines = result.stdout.split('\n').filter(Boolean);
|
|
180
|
+
this.currentHome = lines[0] ?? null;
|
|
181
|
+
return [...new Set(lines.filter((line) => line.startsWith('/')))];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
tmpDirs() {
|
|
185
|
+
return Promise.resolve(this.options.tmpDirs ?? ['/tmp']);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async exists(target) {
|
|
189
|
+
const result = await this.sh('[ -e "$1" ] || [ -L "$1" ]', [target]);
|
|
190
|
+
return result.code === 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Existing paths among `targets`, one round trip per batch.
|
|
195
|
+
* @returns {Promise<Set<string>>}
|
|
196
|
+
*/
|
|
197
|
+
async existsMany(targets) {
|
|
198
|
+
const existing = new Set();
|
|
199
|
+
for (const batch of batches(targets)) {
|
|
200
|
+
const result = await this.sh(EXISTS_SCRIPT, batch);
|
|
201
|
+
result.stdout
|
|
202
|
+
.split('\n')
|
|
203
|
+
.filter(Boolean)
|
|
204
|
+
.forEach((line) => existing.add(line));
|
|
205
|
+
}
|
|
206
|
+
return existing;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async stat(target) {
|
|
210
|
+
const result = await this.executor.run([
|
|
211
|
+
'stat',
|
|
212
|
+
'-c',
|
|
213
|
+
STAT_FORMAT,
|
|
214
|
+
'--',
|
|
215
|
+
target,
|
|
216
|
+
]);
|
|
217
|
+
if (result.code !== 0) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const parsed = parseStatLine(result.stdout.trim());
|
|
221
|
+
if (!parsed) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
type: parsed.type,
|
|
226
|
+
size: parsed.size,
|
|
227
|
+
bytes: parsed.bytes,
|
|
228
|
+
mtimeMs: parsed.mtimeMs,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async readText(target, maxBytes = 1024 * 1024) {
|
|
233
|
+
const result = await this.executor.run([
|
|
234
|
+
'head',
|
|
235
|
+
'-c',
|
|
236
|
+
String(maxBytes),
|
|
237
|
+
'--',
|
|
238
|
+
target,
|
|
239
|
+
]);
|
|
240
|
+
return result.code === 0 ? result.stdout : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async readHeads(files, maxBytes = 4096) {
|
|
244
|
+
const heads = new Map();
|
|
245
|
+
for (const batch of batches(files)) {
|
|
246
|
+
const result = await this.sh(HEADS_SCRIPT, [String(maxBytes), ...batch]);
|
|
247
|
+
for (const [file, text] of splitRecords(result.stdout)) {
|
|
248
|
+
heads.set(file, text);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return heads;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Lists many directories with one round trip per batch.
|
|
256
|
+
* @param {string[]} dirs
|
|
257
|
+
* @returns {Promise<Map<string, object[]>>}
|
|
258
|
+
*/
|
|
259
|
+
async listMany(dirs) {
|
|
260
|
+
const listings = new Map();
|
|
261
|
+
for (const batch of batches(dirs)) {
|
|
262
|
+
const result = await this.sh(LIST_SCRIPT, batch);
|
|
263
|
+
for (const [dir, body] of splitRecords(result.stdout)) {
|
|
264
|
+
const entries = body
|
|
265
|
+
.split('\n')
|
|
266
|
+
.map(parseStatLine)
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.map((entry) => ({
|
|
269
|
+
...entry,
|
|
270
|
+
path: path.posix.join(dir, entry.name),
|
|
271
|
+
}));
|
|
272
|
+
listings.set(dir, entries);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return listings;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async list(dir) {
|
|
279
|
+
return (await this.listMany([dir])).get(dir) ?? [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async usage(target) {
|
|
283
|
+
return (await this.usageMany([target])).get(target) ?? null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Measures many paths with one round trip per batch.
|
|
288
|
+
* @param {string[]} targets
|
|
289
|
+
* @returns {Promise<Map<string, object>>}
|
|
290
|
+
*/
|
|
291
|
+
async usageMany(targets) {
|
|
292
|
+
const usages = new Map();
|
|
293
|
+
for (const batch of batches(targets)) {
|
|
294
|
+
const result = await this.sh(USAGE_MANY_SCRIPT, batch);
|
|
295
|
+
for (const [target, body] of splitRecords(result.stdout)) {
|
|
296
|
+
usages.set(target, parseUsage(body));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return usages;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async readLink(target) {
|
|
303
|
+
const result = await this.executor.run(['readlink', '--', target]);
|
|
304
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async realPath(target) {
|
|
308
|
+
const result = await this.executor.run(['readlink', '-f', '--', target]);
|
|
309
|
+
return result.code === 0 ? result.stdout.trim() || null : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async isRoot() {
|
|
313
|
+
if (this.root === undefined) {
|
|
314
|
+
const result = await this.executor.run(['id', '-u']);
|
|
315
|
+
this.root = result.stdout.trim() === '0';
|
|
316
|
+
}
|
|
317
|
+
return this.root;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async findDirs(roots, { globs, maxDepth = 6, skipNames } = {}) {
|
|
321
|
+
const matches = await this.#find(roots, 'd', globs, maxDepth, skipNames);
|
|
322
|
+
const parents = [...new Set(matches.map((m) => path.posix.dirname(m)))];
|
|
323
|
+
const listings = await this.listMany(parents);
|
|
324
|
+
return matches.map((match) => {
|
|
325
|
+
const parent = path.posix.dirname(match);
|
|
326
|
+
const siblings = (listings.get(parent) ?? []).map(
|
|
327
|
+
({ name, type, mtimeMs }) => ({ name, type, mtimeMs })
|
|
328
|
+
);
|
|
329
|
+
return {
|
|
330
|
+
path: match,
|
|
331
|
+
name: path.posix.basename(match),
|
|
332
|
+
parent,
|
|
333
|
+
siblings,
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
|
|
339
|
+
return this.#find(roots, 'f', names, maxDepth, skipNames);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Runs one `find` that prints entries of `type` matching `globs` (without
|
|
344
|
+
* descending into matched directories) and prunes `skipNames`.
|
|
345
|
+
*/
|
|
346
|
+
async #find(roots, type, globs, maxDepth, skipNames) {
|
|
347
|
+
if (roots.length === 0 || globs.length === 0) {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
const skip = skipNames ?? this.skipNames ?? [];
|
|
351
|
+
const pruneSkipped =
|
|
352
|
+
skip.length > 0
|
|
353
|
+
? ['-type', 'd', '(', ...findNameExpression(skip), ')', '-prune', '-o']
|
|
354
|
+
: [];
|
|
355
|
+
const argv = [
|
|
356
|
+
'find',
|
|
357
|
+
...roots,
|
|
358
|
+
'-mindepth',
|
|
359
|
+
'1',
|
|
360
|
+
'-maxdepth',
|
|
361
|
+
String(maxDepth),
|
|
362
|
+
...(type === 'd'
|
|
363
|
+
? ['-type', 'd', '(', ...findNameExpression(globs), ')', '-print']
|
|
364
|
+
: []),
|
|
365
|
+
...(type === 'd' ? ['-prune', '-o'] : []),
|
|
366
|
+
...pruneSkipped,
|
|
367
|
+
...(type === 'f'
|
|
368
|
+
? ['-type', 'f', '(', ...findNameExpression(globs), ')', '-print']
|
|
369
|
+
: ['-name', '']),
|
|
370
|
+
];
|
|
371
|
+
const result = await this.executor.run(argv);
|
|
372
|
+
return result.stdout.split('\n').filter(Boolean);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async remove(targets) {
|
|
376
|
+
for (const batch of batches(targets)) {
|
|
377
|
+
const result = await this.sh(REMOVE_SCRIPT, batch);
|
|
378
|
+
if (result.code !== 0) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`rm failed in ${this.label}: ${result.stderr.trim() || result.code}`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async processes() {
|
|
387
|
+
const result = await this.sh(PROCESS_SCRIPT);
|
|
388
|
+
if (result.code !== 0) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const [selfPid, ...lines] = result.stdout.split('\n');
|
|
392
|
+
return lines
|
|
393
|
+
.map((line) =>
|
|
394
|
+
/^(\d+)\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|(.*)$/.exec(line)
|
|
395
|
+
)
|
|
396
|
+
.filter((m) => m && !m[2].startsWith('Z '))
|
|
397
|
+
.map((m) => ({
|
|
398
|
+
pid: Number(m[1]),
|
|
399
|
+
startTime: m[2].split(' ')[1] || null,
|
|
400
|
+
name: m[3],
|
|
401
|
+
aliases: processAliases(m[4], m[5]),
|
|
402
|
+
cwd: m[6] || null,
|
|
403
|
+
}))
|
|
404
|
+
.filter((proc) => String(proc.pid) !== selfPid.trim());
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async openPaths() {
|
|
408
|
+
const result = await this.sh(OPEN_PATHS_SCRIPT);
|
|
409
|
+
if (result.code !== 0) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
return new Set(
|
|
413
|
+
result.stdout
|
|
414
|
+
.split('\n')
|
|
415
|
+
.filter((line) => line.startsWith('/'))
|
|
416
|
+
.map((line) => line.replace(/ \(deleted\)$/, ''))
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
run(argv, options) {
|
|
421
|
+
return this.executor.run(argv, options);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async which(command) {
|
|
425
|
+
const result = await this.sh(`command -v ${shellQuote(command)}`);
|
|
426
|
+
return result.code === 0 && result.stdout.trim() !== '';
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async diskUsage(target) {
|
|
430
|
+
const result = await this.executor.run(['df', '-Pk', target]);
|
|
431
|
+
return result.code === 0 ? parseDf(result.stdout) : null;
|
|
432
|
+
}
|
|
433
|
+
}
|
package/src/exec.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command execution primitives.
|
|
3
|
+
*
|
|
4
|
+
* An executor runs argv arrays somewhere: on the host, or inside a container
|
|
5
|
+
* through `docker exec`. Executors chain, so a container inside a
|
|
6
|
+
* Docker-in-Docker daemon is reached by wrapping the outer container's
|
|
7
|
+
* executor. Every higher layer (filesystem adapters, Docker client, git
|
|
8
|
+
* checks) talks to an executor and never cares how deep it is.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
14
|
+
const MAX_CAPTURE_BYTES = 256 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Debug tracing, off by default. Enable with DSS_DEBUG=1 or `--verbose`.
|
|
18
|
+
*/
|
|
19
|
+
let traceEnabled = false;
|
|
20
|
+
try {
|
|
21
|
+
traceEnabled = Boolean(globalThis.process?.env?.DSS_DEBUG);
|
|
22
|
+
} catch {
|
|
23
|
+
traceEnabled = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setTrace(enabled) {
|
|
27
|
+
traceEnabled = Boolean(enabled);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function trace(...parts) {
|
|
31
|
+
if (traceEnabled) {
|
|
32
|
+
console.error('[dss]', ...parts);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Spawns argv and captures its output.
|
|
38
|
+
* @param {string[]} argv
|
|
39
|
+
* @param {{input?: string, stdin?: import('node:stream').Readable,
|
|
40
|
+
* timeoutMs?: number, cwd?: string, env?: object}} [options]
|
|
41
|
+
* @returns {Promise<{code: number, stdout: string, stderr: string, error?: Error}>}
|
|
42
|
+
*/
|
|
43
|
+
export function runProcess(argv, options = {}) {
|
|
44
|
+
const { input, timeoutMs = DEFAULT_TIMEOUT_MS, cwd, env } = options;
|
|
45
|
+
trace('exec', JSON.stringify(argv));
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let child;
|
|
48
|
+
try {
|
|
49
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
50
|
+
cwd,
|
|
51
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
52
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
resolve({ code: 127, stdout: '', stderr: String(error), error });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const stdout = [];
|
|
60
|
+
const stderr = [];
|
|
61
|
+
let captured = 0;
|
|
62
|
+
let settled = false;
|
|
63
|
+
const startedAt = Date.now();
|
|
64
|
+
const finish = (result) => {
|
|
65
|
+
if (!settled) {
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
trace(`exit ${result.code} after ${Date.now() - startedAt}ms`);
|
|
69
|
+
resolve(result);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
child.kill('SIGKILL');
|
|
74
|
+
finish({
|
|
75
|
+
code: 124,
|
|
76
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
77
|
+
stderr: `timed out after ${timeoutMs}ms`,
|
|
78
|
+
error: new Error(`timed out after ${timeoutMs}ms`),
|
|
79
|
+
});
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
const collect = (target) => (chunk) => {
|
|
82
|
+
captured += chunk.length;
|
|
83
|
+
if (captured <= MAX_CAPTURE_BYTES) {
|
|
84
|
+
target.push(chunk);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
child.stdout.on('data', collect(stdout));
|
|
88
|
+
child.stderr.on('data', collect(stderr));
|
|
89
|
+
child.on('error', (error) => {
|
|
90
|
+
finish({ code: 127, stdout: '', stderr: String(error), error });
|
|
91
|
+
});
|
|
92
|
+
child.on('close', (code) => {
|
|
93
|
+
finish({
|
|
94
|
+
code: code ?? 1,
|
|
95
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
96
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
child.stdin.on('error', () => {});
|
|
100
|
+
if (options.stdin) {
|
|
101
|
+
options.stdin.pipe(child.stdin);
|
|
102
|
+
} else if (input !== undefined) {
|
|
103
|
+
child.stdin.end(input);
|
|
104
|
+
} else {
|
|
105
|
+
child.stdin.end();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Pipes the stdout of an already spawned `source` process into `argv`.
|
|
112
|
+
* The result fails when either side fails.
|
|
113
|
+
* @param {import('node:child_process').ChildProcess} source
|
|
114
|
+
* @param {string[]} argv
|
|
115
|
+
* @param {{timeoutMs?: number, cwd?: string}} [options]
|
|
116
|
+
*/
|
|
117
|
+
export async function pipeInto(source, argv, options = {}) {
|
|
118
|
+
const sourceErr = [];
|
|
119
|
+
source.stderr?.on('data', (chunk) => sourceErr.push(chunk));
|
|
120
|
+
const sourceDone = new Promise((resolve) => {
|
|
121
|
+
source.on('error', (error) => resolve({ code: 127, error }));
|
|
122
|
+
source.on('close', (code) => resolve({ code: code ?? 1 }));
|
|
123
|
+
});
|
|
124
|
+
const result = await runProcess(argv, { ...options, stdin: source.stdout });
|
|
125
|
+
const upstream = await sourceDone;
|
|
126
|
+
if (upstream.code !== 0) {
|
|
127
|
+
return {
|
|
128
|
+
...result,
|
|
129
|
+
code: upstream.code,
|
|
130
|
+
stderr: `${Buffer.concat(sourceErr).toString('utf8')}${upstream.error ?? ''}${result.stderr}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Executor that runs commands on the machine running dss.
|
|
138
|
+
*/
|
|
139
|
+
export function hostExecutor() {
|
|
140
|
+
return {
|
|
141
|
+
label: 'host',
|
|
142
|
+
depth: 0,
|
|
143
|
+
run: (argv, options) => runProcess(argv, options),
|
|
144
|
+
spawn: (argv) =>
|
|
145
|
+
spawn(argv[0], argv.slice(1), {
|
|
146
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Executor that runs commands inside a container via `docker exec`.
|
|
154
|
+
* @param {object} parent executor that can reach the container's daemon
|
|
155
|
+
* @param {string} containerId
|
|
156
|
+
* @param {{docker?: string, label?: string, user?: string}} [options]
|
|
157
|
+
*/
|
|
158
|
+
export function containerExecutor(parent, containerId, options = {}) {
|
|
159
|
+
const docker = options.docker ?? 'docker';
|
|
160
|
+
const wrap = (argv, interactive) => [
|
|
161
|
+
docker,
|
|
162
|
+
'exec',
|
|
163
|
+
...(interactive ? ['-i'] : []),
|
|
164
|
+
...(options.user ? ['--user', options.user] : []),
|
|
165
|
+
containerId,
|
|
166
|
+
...argv,
|
|
167
|
+
];
|
|
168
|
+
return {
|
|
169
|
+
label: options.label ?? `${parent.label}/${containerId.slice(0, 12)}`,
|
|
170
|
+
depth: (parent.depth ?? 0) + 1,
|
|
171
|
+
containerId,
|
|
172
|
+
parent,
|
|
173
|
+
run: (argv, runOptions = {}) =>
|
|
174
|
+
parent.run(wrap(argv, runOptions.input !== undefined), runOptions),
|
|
175
|
+
spawn: (argv) => parent.spawn(wrap(argv, false)),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Quotes a value for inclusion in a POSIX shell script.
|
|
181
|
+
* @param {string} value
|
|
182
|
+
*/
|
|
183
|
+
export function shellQuote(value) {
|
|
184
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
185
|
+
}
|