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.
@@ -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
+ }
@@ -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
+ }