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