start-command 0.7.6 → 0.10.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/ARCHITECTURE.md +297 -0
- package/CHANGELOG.md +46 -0
- package/README.md +68 -7
- package/REQUIREMENTS.md +72 -1
- package/experiments/user-isolation-research.md +83 -0
- package/package.json +1 -1
- package/src/bin/cli.js +131 -36
- package/src/lib/args-parser.js +95 -5
- package/src/lib/isolation.js +184 -43
- package/src/lib/user-manager.js +429 -0
- package/test/args-parser.test.js +309 -0
- package/test/docker-autoremove.test.js +169 -0
- package/test/isolation-cleanup.test.js +377 -0
- package/test/isolation.test.js +233 -0
- package/test/user-manager.test.js +286 -0
package/test/args-parser.test.js
CHANGED
|
@@ -219,6 +219,136 @@ describe('parseArgs', () => {
|
|
|
219
219
|
});
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
describe('keep-alive option', () => {
|
|
223
|
+
it('should parse --keep-alive flag', () => {
|
|
224
|
+
const result = parseArgs([
|
|
225
|
+
'--isolated',
|
|
226
|
+
'tmux',
|
|
227
|
+
'--keep-alive',
|
|
228
|
+
'--',
|
|
229
|
+
'npm',
|
|
230
|
+
'test',
|
|
231
|
+
]);
|
|
232
|
+
assert.strictEqual(result.wrapperOptions.keepAlive, true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should parse -k shorthand', () => {
|
|
236
|
+
const result = parseArgs(['-i', 'screen', '-k', '--', 'npm', 'start']);
|
|
237
|
+
assert.strictEqual(result.wrapperOptions.keepAlive, true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should default keepAlive to false', () => {
|
|
241
|
+
const result = parseArgs(['-i', 'tmux', '--', 'npm', 'test']);
|
|
242
|
+
assert.strictEqual(result.wrapperOptions.keepAlive, false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should throw error for keep-alive without isolation', () => {
|
|
246
|
+
assert.throws(() => {
|
|
247
|
+
parseArgs(['--keep-alive', '--', 'npm', 'test']);
|
|
248
|
+
}, /--keep-alive option is only valid with --isolated/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should work with detached mode', () => {
|
|
252
|
+
const result = parseArgs([
|
|
253
|
+
'-i',
|
|
254
|
+
'screen',
|
|
255
|
+
'-d',
|
|
256
|
+
'-k',
|
|
257
|
+
'--',
|
|
258
|
+
'npm',
|
|
259
|
+
'start',
|
|
260
|
+
]);
|
|
261
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
262
|
+
assert.strictEqual(result.wrapperOptions.detached, true);
|
|
263
|
+
assert.strictEqual(result.wrapperOptions.keepAlive, true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should work with docker', () => {
|
|
267
|
+
const result = parseArgs([
|
|
268
|
+
'-i',
|
|
269
|
+
'docker',
|
|
270
|
+
'--image',
|
|
271
|
+
'node:20',
|
|
272
|
+
'-k',
|
|
273
|
+
'--',
|
|
274
|
+
'npm',
|
|
275
|
+
'test',
|
|
276
|
+
]);
|
|
277
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
278
|
+
assert.strictEqual(result.wrapperOptions.image, 'node:20');
|
|
279
|
+
assert.strictEqual(result.wrapperOptions.keepAlive, true);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('auto-remove-docker-container option', () => {
|
|
284
|
+
it('should parse --auto-remove-docker-container flag', () => {
|
|
285
|
+
const result = parseArgs([
|
|
286
|
+
'--isolated',
|
|
287
|
+
'docker',
|
|
288
|
+
'--image',
|
|
289
|
+
'alpine',
|
|
290
|
+
'--auto-remove-docker-container',
|
|
291
|
+
'--',
|
|
292
|
+
'npm',
|
|
293
|
+
'test',
|
|
294
|
+
]);
|
|
295
|
+
assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should default autoRemoveDockerContainer to false', () => {
|
|
299
|
+
const result = parseArgs([
|
|
300
|
+
'-i',
|
|
301
|
+
'docker',
|
|
302
|
+
'--image',
|
|
303
|
+
'alpine',
|
|
304
|
+
'--',
|
|
305
|
+
'npm',
|
|
306
|
+
'test',
|
|
307
|
+
]);
|
|
308
|
+
assert.strictEqual(
|
|
309
|
+
result.wrapperOptions.autoRemoveDockerContainer,
|
|
310
|
+
false
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should throw error for auto-remove-docker-container without docker isolation', () => {
|
|
315
|
+
assert.throws(() => {
|
|
316
|
+
parseArgs([
|
|
317
|
+
'-i',
|
|
318
|
+
'tmux',
|
|
319
|
+
'--auto-remove-docker-container',
|
|
320
|
+
'--',
|
|
321
|
+
'npm',
|
|
322
|
+
'test',
|
|
323
|
+
]);
|
|
324
|
+
}, /--auto-remove-docker-container option is only valid with --isolated docker/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should throw error for auto-remove-docker-container without isolation', () => {
|
|
328
|
+
assert.throws(() => {
|
|
329
|
+
parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
|
|
330
|
+
}, /--auto-remove-docker-container option is only valid with --isolated docker/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should work with keep-alive and auto-remove-docker-container', () => {
|
|
334
|
+
const result = parseArgs([
|
|
335
|
+
'-i',
|
|
336
|
+
'docker',
|
|
337
|
+
'--image',
|
|
338
|
+
'node:20',
|
|
339
|
+
'-k',
|
|
340
|
+
'--auto-remove-docker-container',
|
|
341
|
+
'--',
|
|
342
|
+
'npm',
|
|
343
|
+
'test',
|
|
344
|
+
]);
|
|
345
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
346
|
+
assert.strictEqual(result.wrapperOptions.image, 'node:20');
|
|
347
|
+
assert.strictEqual(result.wrapperOptions.keepAlive, true);
|
|
348
|
+
assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
222
352
|
describe('command without separator', () => {
|
|
223
353
|
it('should parse command after options without separator', () => {
|
|
224
354
|
const result = parseArgs(['-i', 'tmux', '-d', 'npm', 'start']);
|
|
@@ -383,3 +513,182 @@ describe('VALID_BACKENDS', () => {
|
|
|
383
513
|
assert.ok(VALID_BACKENDS.includes('docker'));
|
|
384
514
|
});
|
|
385
515
|
});
|
|
516
|
+
|
|
517
|
+
describe('user isolation option', () => {
|
|
518
|
+
it('should parse --isolated-user without value (auto-generated username)', () => {
|
|
519
|
+
const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
|
|
520
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
521
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
522
|
+
assert.strictEqual(result.command, 'npm test');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('should parse --isolated-user with custom username', () => {
|
|
526
|
+
const result = parseArgs([
|
|
527
|
+
'--isolated-user',
|
|
528
|
+
'myrunner',
|
|
529
|
+
'--',
|
|
530
|
+
'npm',
|
|
531
|
+
'test',
|
|
532
|
+
]);
|
|
533
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
534
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myrunner');
|
|
535
|
+
assert.strictEqual(result.command, 'npm test');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('should parse -u shorthand', () => {
|
|
539
|
+
const result = parseArgs(['-u', '--', 'npm', 'start']);
|
|
540
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
541
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should parse -u with custom username', () => {
|
|
545
|
+
const result = parseArgs(['-u', 'testuser', '--', 'npm', 'start']);
|
|
546
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
547
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should parse --isolated-user=value format', () => {
|
|
551
|
+
const result = parseArgs([
|
|
552
|
+
'--isolated-user=myrunner',
|
|
553
|
+
'--',
|
|
554
|
+
'npm',
|
|
555
|
+
'start',
|
|
556
|
+
]);
|
|
557
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
558
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myrunner');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('should work with isolation options', () => {
|
|
562
|
+
const result = parseArgs([
|
|
563
|
+
'--isolated',
|
|
564
|
+
'screen',
|
|
565
|
+
'--isolated-user',
|
|
566
|
+
'testuser',
|
|
567
|
+
'--',
|
|
568
|
+
'npm',
|
|
569
|
+
'start',
|
|
570
|
+
]);
|
|
571
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
572
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
573
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
574
|
+
assert.strictEqual(result.command, 'npm start');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should work without isolation (standalone user isolation)', () => {
|
|
578
|
+
const result = parseArgs(['--isolated-user', '--', 'node', 'server.js']);
|
|
579
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
580
|
+
assert.strictEqual(result.wrapperOptions.isolated, null);
|
|
581
|
+
assert.strictEqual(result.command, 'node server.js');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should accept valid usernames', () => {
|
|
585
|
+
const validUsernames = [
|
|
586
|
+
'john',
|
|
587
|
+
'www-data',
|
|
588
|
+
'user123',
|
|
589
|
+
'john-doe',
|
|
590
|
+
'user_1',
|
|
591
|
+
];
|
|
592
|
+
for (const username of validUsernames) {
|
|
593
|
+
assert.doesNotThrow(() => {
|
|
594
|
+
parseArgs(['--isolated-user', username, '--', 'echo', 'test']);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('should reject invalid username formats with --isolated-user=value syntax', () => {
|
|
600
|
+
const invalidUsernames = ['john@doe', 'user.name', 'user/name'];
|
|
601
|
+
for (const username of invalidUsernames) {
|
|
602
|
+
assert.throws(() => {
|
|
603
|
+
parseArgs([`--isolated-user=${username}`, '--', 'echo', 'test']);
|
|
604
|
+
}, /Invalid username format/);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should not consume invalid username as argument (treats as command)', () => {
|
|
609
|
+
// When --isolated-user is followed by an invalid username format, it doesn't consume it
|
|
610
|
+
// The invalid username becomes part of the command instead
|
|
611
|
+
const result = parseArgs([
|
|
612
|
+
'--isolated-user',
|
|
613
|
+
'john@doe',
|
|
614
|
+
'--',
|
|
615
|
+
'echo',
|
|
616
|
+
'test',
|
|
617
|
+
]);
|
|
618
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
619
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
620
|
+
// john@doe is not consumed as username, but the -- separator means it's not in command either
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('should throw error for user with docker isolation', () => {
|
|
624
|
+
assert.throws(() => {
|
|
625
|
+
parseArgs([
|
|
626
|
+
'--isolated',
|
|
627
|
+
'docker',
|
|
628
|
+
'--image',
|
|
629
|
+
'node:20',
|
|
630
|
+
'--isolated-user',
|
|
631
|
+
'--',
|
|
632
|
+
'npm',
|
|
633
|
+
'install',
|
|
634
|
+
]);
|
|
635
|
+
}, /--isolated-user is not supported with Docker isolation/);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('should work with tmux isolation', () => {
|
|
639
|
+
const result = parseArgs([
|
|
640
|
+
'-i',
|
|
641
|
+
'tmux',
|
|
642
|
+
'--isolated-user',
|
|
643
|
+
'testuser',
|
|
644
|
+
'--',
|
|
645
|
+
'npm',
|
|
646
|
+
'test',
|
|
647
|
+
]);
|
|
648
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
|
|
649
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
650
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe('keep-user option', () => {
|
|
655
|
+
it('should parse --keep-user flag', () => {
|
|
656
|
+
const result = parseArgs([
|
|
657
|
+
'--isolated-user',
|
|
658
|
+
'--keep-user',
|
|
659
|
+
'--',
|
|
660
|
+
'npm',
|
|
661
|
+
'test',
|
|
662
|
+
]);
|
|
663
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
664
|
+
assert.strictEqual(result.wrapperOptions.keepUser, true);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('should default keepUser to false', () => {
|
|
668
|
+
const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
|
|
669
|
+
assert.strictEqual(result.wrapperOptions.keepUser, false);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('should throw error for keep-user without user', () => {
|
|
673
|
+
assert.throws(() => {
|
|
674
|
+
parseArgs(['--keep-user', '--', 'npm', 'test']);
|
|
675
|
+
}, /--keep-user option is only valid with --isolated-user/);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should work with user and isolation options', () => {
|
|
679
|
+
const result = parseArgs([
|
|
680
|
+
'-i',
|
|
681
|
+
'screen',
|
|
682
|
+
'--isolated-user',
|
|
683
|
+
'testuser',
|
|
684
|
+
'--keep-user',
|
|
685
|
+
'--',
|
|
686
|
+
'npm',
|
|
687
|
+
'start',
|
|
688
|
+
]);
|
|
689
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
690
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
691
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
692
|
+
assert.strictEqual(result.wrapperOptions.keepUser, true);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Docker auto-remove container feature
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { describe, it } = require('node:test');
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const { isCommandAvailable } = require('../src/lib/isolation');
|
|
9
|
+
const { runInDocker } = require('../src/lib/isolation');
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
// Helper to wait for a condition with timeout
|
|
13
|
+
async function waitFor(conditionFn, timeout = 5000, interval = 100) {
|
|
14
|
+
const startTime = Date.now();
|
|
15
|
+
while (Date.now() - startTime < timeout) {
|
|
16
|
+
if (conditionFn()) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper function to check if docker daemon is running
|
|
25
|
+
function isDockerRunning() {
|
|
26
|
+
if (!isCommandAvailable('docker')) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
execSync('docker info', { stdio: 'ignore', timeout: 5000 });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('Docker Auto-Remove Container Feature', () => {
|
|
38
|
+
// These tests verify the --auto-remove-docker-container option
|
|
39
|
+
// which automatically removes the container after exit (disabled by default)
|
|
40
|
+
|
|
41
|
+
describe('auto-remove enabled', () => {
|
|
42
|
+
it('should automatically remove container when autoRemoveDockerContainer is true', async () => {
|
|
43
|
+
if (!isDockerRunning()) {
|
|
44
|
+
console.log(' Skipping: docker not available or daemon not running');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const containerName = `test-autoremove-${Date.now()}`;
|
|
49
|
+
|
|
50
|
+
// Run command with autoRemoveDockerContainer enabled
|
|
51
|
+
const result = await runInDocker('echo "test" && sleep 0.5', {
|
|
52
|
+
image: 'alpine:latest',
|
|
53
|
+
session: containerName,
|
|
54
|
+
detached: true,
|
|
55
|
+
keepAlive: false,
|
|
56
|
+
autoRemoveDockerContainer: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.strictEqual(result.success, true);
|
|
60
|
+
assert.ok(
|
|
61
|
+
result.message.includes('automatically removed'),
|
|
62
|
+
'Message should indicate auto-removal'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Wait for container to finish and be removed
|
|
66
|
+
const containerRemoved = await waitFor(() => {
|
|
67
|
+
try {
|
|
68
|
+
execSync(`docker inspect -f '{{.State.Status}}' ${containerName}`, {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
71
|
+
});
|
|
72
|
+
return false; // Container still exists
|
|
73
|
+
} catch {
|
|
74
|
+
return true; // Container does not exist (removed)
|
|
75
|
+
}
|
|
76
|
+
}, 10000);
|
|
77
|
+
|
|
78
|
+
assert.ok(
|
|
79
|
+
containerRemoved,
|
|
80
|
+
'Container should be automatically removed after exit with --auto-remove-docker-container'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Double-check with docker ps -a that container is completely removed
|
|
84
|
+
try {
|
|
85
|
+
const allContainers = execSync('docker ps -a', {
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
88
|
+
});
|
|
89
|
+
assert.ok(
|
|
90
|
+
!allContainers.includes(containerName),
|
|
91
|
+
'Container should NOT appear in docker ps -a (completely removed)'
|
|
92
|
+
);
|
|
93
|
+
console.log(
|
|
94
|
+
' ✓ Docker container auto-removed after exit (filesystem not preserved)'
|
|
95
|
+
);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
assert.fail(`Failed to verify container removal: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// No cleanup needed - container should already be removed
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('auto-remove disabled (default)', () => {
|
|
105
|
+
it('should preserve container filesystem by default (without autoRemoveDockerContainer)', async () => {
|
|
106
|
+
if (!isDockerRunning()) {
|
|
107
|
+
console.log(' Skipping: docker not available or daemon not running');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const containerName = `test-preserve-${Date.now()}`;
|
|
112
|
+
|
|
113
|
+
// Run command without autoRemoveDockerContainer
|
|
114
|
+
const result = await runInDocker('echo "test" && sleep 0.1', {
|
|
115
|
+
image: 'alpine:latest',
|
|
116
|
+
session: containerName,
|
|
117
|
+
detached: true,
|
|
118
|
+
keepAlive: false,
|
|
119
|
+
autoRemoveDockerContainer: false,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
assert.strictEqual(result.success, true);
|
|
123
|
+
assert.ok(
|
|
124
|
+
result.message.includes('filesystem will be preserved'),
|
|
125
|
+
'Message should indicate filesystem preservation'
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Wait for container to exit
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
try {
|
|
131
|
+
const status = execSync(
|
|
132
|
+
`docker inspect -f '{{.State.Status}}' ${containerName}`,
|
|
133
|
+
{
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
136
|
+
}
|
|
137
|
+
).trim();
|
|
138
|
+
return status === 'exited';
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}, 10000);
|
|
143
|
+
|
|
144
|
+
// Container should still exist (in exited state)
|
|
145
|
+
try {
|
|
146
|
+
const allContainers = execSync('docker ps -a', {
|
|
147
|
+
encoding: 'utf8',
|
|
148
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
149
|
+
});
|
|
150
|
+
assert.ok(
|
|
151
|
+
allContainers.includes(containerName),
|
|
152
|
+
'Container should appear in docker ps -a (filesystem preserved)'
|
|
153
|
+
);
|
|
154
|
+
console.log(
|
|
155
|
+
' ✓ Docker container filesystem preserved by default (can be re-entered)'
|
|
156
|
+
);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
assert.fail(`Failed to verify container preservation: ${err.message}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Clean up
|
|
162
|
+
try {
|
|
163
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore cleanup errors
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|