start-command 0.7.5 → 0.9.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.
@@ -6,11 +6,13 @@
6
6
  * 2. $ [wrapper-options] command [command-options]
7
7
  *
8
8
  * Wrapper Options:
9
- * --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
10
- * --attached, -a Run in attached mode (foreground)
11
- * --detached, -d Run in detached mode (background)
12
- * --session, -s <name> Session name for isolation
13
- * --image <image> Docker image (required for docker isolation)
9
+ * --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
10
+ * --attached, -a Run in attached mode (foreground)
11
+ * --detached, -d Run in detached mode (background)
12
+ * --session, -s <name> Session name for isolation
13
+ * --image <image> Docker image (required for docker isolation)
14
+ * --keep-alive, -k Keep isolation environment alive after command exits
15
+ * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
14
16
  */
15
17
 
16
18
  // Debug mode from environment
@@ -34,6 +36,8 @@ function parseArgs(args) {
34
36
  detached: false, // Run in detached mode
35
37
  session: null, // Session name
36
38
  image: null, // Docker image
39
+ keepAlive: false, // Keep environment alive after command exits
40
+ autoRemoveDockerContainer: false, // Auto-remove docker container after exit
37
41
  };
38
42
 
39
43
  let commandArgs = [];
@@ -171,6 +175,18 @@ function parseOption(args, index, options) {
171
175
  return 1;
172
176
  }
173
177
 
178
+ // --keep-alive or -k
179
+ if (arg === '--keep-alive' || arg === '-k') {
180
+ options.keepAlive = true;
181
+ return 1;
182
+ }
183
+
184
+ // --auto-remove-docker-container
185
+ if (arg === '--auto-remove-docker-container') {
186
+ options.autoRemoveDockerContainer = true;
187
+ return 1;
188
+ }
189
+
174
190
  // Not a recognized wrapper option
175
191
  return 0;
176
192
  }
@@ -213,6 +229,18 @@ function validateOptions(options) {
213
229
  if (options.image && options.isolated !== 'docker') {
214
230
  throw new Error('--image option is only valid with --isolated docker');
215
231
  }
232
+
233
+ // Keep-alive is only valid with isolation
234
+ if (options.keepAlive && !options.isolated) {
235
+ throw new Error('--keep-alive option is only valid with --isolated');
236
+ }
237
+
238
+ // Auto-remove-docker-container is only valid with docker isolation
239
+ if (options.autoRemoveDockerContainer && options.isolated !== 'docker') {
240
+ throw new Error(
241
+ '--auto-remove-docker-container option is only valid with --isolated docker'
242
+ );
243
+ }
216
244
  }
217
245
 
218
246
  /**
@@ -299,7 +299,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
299
299
  /**
300
300
  * Run command in GNU Screen
301
301
  * @param {string} command - Command to execute
302
- * @param {object} options - Options (session, detached)
302
+ * @param {object} options - Options (session, detached, keepAlive)
303
303
  * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
304
304
  */
305
305
  function runInScreen(command, options = {}) {
@@ -319,10 +319,28 @@ function runInScreen(command, options = {}) {
319
319
  try {
320
320
  if (options.detached) {
321
321
  // Detached mode: screen -dmS <session> <shell> -c '<command>'
322
- const screenArgs = ['-dmS', sessionName, shell, shellArg, command];
322
+ // By default (keepAlive=false), the session will exit after command completes
323
+ // With keepAlive=true, we start a shell that runs the command but stays alive
324
+ let effectiveCommand = command;
325
+
326
+ if (options.keepAlive) {
327
+ // With keep-alive: run command, then keep shell open
328
+ // Use exec to replace the shell, but first run command
329
+ effectiveCommand = `${command}; exec ${shell}`;
330
+ }
331
+ // Without keep-alive: command runs and session exits naturally when done
332
+
333
+ const screenArgs = [
334
+ '-dmS',
335
+ sessionName,
336
+ shell,
337
+ shellArg,
338
+ effectiveCommand,
339
+ ];
323
340
 
324
341
  if (DEBUG) {
325
342
  console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
343
+ console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
326
344
  }
327
345
 
328
346
  // Use spawnSync with array arguments to avoid shell quoting issues
@@ -336,10 +354,18 @@ function runInScreen(command, options = {}) {
336
354
  throw result.error;
337
355
  }
338
356
 
357
+ let message = `Command started in detached screen session: ${sessionName}`;
358
+ if (options.keepAlive) {
359
+ message += `\nSession will stay alive after command completes.`;
360
+ } else {
361
+ message += `\nSession will exit automatically after command completes.`;
362
+ }
363
+ message += `\nReattach with: screen -r ${sessionName}`;
364
+
339
365
  return Promise.resolve({
340
366
  success: true,
341
367
  sessionName,
342
- message: `Command started in detached screen session: ${sessionName}\nReattach with: screen -r ${sessionName}`,
368
+ message,
343
369
  });
344
370
  } else {
345
371
  // Attached mode: always use detached mode with log capture
@@ -371,7 +397,7 @@ function runInScreen(command, options = {}) {
371
397
  /**
372
398
  * Run command in tmux
373
399
  * @param {string} command - Command to execute
374
- * @param {object} options - Options (session, detached)
400
+ * @param {object} options - Options (session, detached, keepAlive)
375
401
  * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
376
402
  */
377
403
  function runInTmux(command, options = {}) {
@@ -385,24 +411,48 @@ function runInTmux(command, options = {}) {
385
411
  }
386
412
 
387
413
  const sessionName = options.session || generateSessionName('tmux');
414
+ const shellInfo = getShell();
415
+ const { shell } = shellInfo;
388
416
 
389
417
  try {
390
418
  if (options.detached) {
391
419
  // Detached mode: tmux new-session -d -s <session> '<command>'
420
+ // By default (keepAlive=false), the session will exit after command completes
421
+ // With keepAlive=true, we keep the shell alive after the command
422
+ let effectiveCommand = command;
423
+
424
+ if (options.keepAlive) {
425
+ // With keep-alive: run command, then keep shell open
426
+ effectiveCommand = `${command}; exec ${shell}`;
427
+ }
428
+ // Without keep-alive: command runs and session exits naturally when done
429
+
392
430
  if (DEBUG) {
393
431
  console.log(
394
- `[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${command}"`
432
+ `[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`
395
433
  );
434
+ console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
396
435
  }
397
436
 
398
- execSync(`tmux new-session -d -s "${sessionName}" "${command}"`, {
399
- stdio: 'inherit',
400
- });
437
+ execSync(
438
+ `tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`,
439
+ {
440
+ stdio: 'inherit',
441
+ }
442
+ );
443
+
444
+ let message = `Command started in detached tmux session: ${sessionName}`;
445
+ if (options.keepAlive) {
446
+ message += `\nSession will stay alive after command completes.`;
447
+ } else {
448
+ message += `\nSession will exit automatically after command completes.`;
449
+ }
450
+ message += `\nReattach with: tmux attach -t ${sessionName}`;
401
451
 
402
452
  return Promise.resolve({
403
453
  success: true,
404
454
  sessionName,
405
- message: `Command started in detached tmux session: ${sessionName}\nReattach with: tmux attach -t ${sessionName}`,
455
+ message,
406
456
  });
407
457
  } else {
408
458
  // Attached mode: tmux new-session -s <session> '<command>'
@@ -451,7 +501,7 @@ function runInTmux(command, options = {}) {
451
501
  /**
452
502
  * Run command in Docker container
453
503
  * @param {string} command - Command to execute
454
- * @param {object} options - Options (image, session/name, detached)
504
+ * @param {object} options - Options (image, session/name, detached, keepAlive, autoRemoveDockerContainer)
455
505
  * @returns {Promise<{success: boolean, containerName: string, message: string}>}
456
506
  */
457
507
  function runInDocker(command, options = {}) {
@@ -477,6 +527,16 @@ function runInDocker(command, options = {}) {
477
527
  try {
478
528
  if (options.detached) {
479
529
  // Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
530
+ // By default (keepAlive=false), the container exits after command completes
531
+ // With keepAlive=true, we keep the container running with a shell
532
+ let effectiveCommand = command;
533
+
534
+ if (options.keepAlive) {
535
+ // With keep-alive: run command, then keep shell alive
536
+ effectiveCommand = `${command}; exec /bin/sh`;
537
+ }
538
+ // Without keep-alive: container exits naturally when command completes
539
+
480
540
  const dockerArgs = [
481
541
  'run',
482
542
  '-d',
@@ -485,22 +545,47 @@ function runInDocker(command, options = {}) {
485
545
  options.image,
486
546
  '/bin/sh',
487
547
  '-c',
488
- command,
548
+ effectiveCommand,
489
549
  ];
490
550
 
551
+ // Add --rm flag if autoRemoveDockerContainer is true
552
+ // Note: --rm must come before the image name
553
+ if (options.autoRemoveDockerContainer) {
554
+ dockerArgs.splice(2, 0, '--rm');
555
+ }
556
+
491
557
  if (DEBUG) {
492
558
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
559
+ console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
560
+ console.log(
561
+ `[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
562
+ );
493
563
  }
494
564
 
495
565
  const containerId = execSync(`docker ${dockerArgs.join(' ')}`, {
496
566
  encoding: 'utf8',
497
567
  }).trim();
498
568
 
569
+ let message = `Command started in detached docker container: ${containerName}`;
570
+ message += `\nContainer ID: ${containerId.substring(0, 12)}`;
571
+ if (options.keepAlive) {
572
+ message += `\nContainer will stay alive after command completes.`;
573
+ } else {
574
+ message += `\nContainer will exit automatically after command completes.`;
575
+ }
576
+ if (options.autoRemoveDockerContainer) {
577
+ message += `\nContainer will be automatically removed after exit.`;
578
+ } else {
579
+ message += `\nContainer filesystem will be preserved after exit.`;
580
+ }
581
+ message += `\nAttach with: docker attach ${containerName}`;
582
+ message += `\nView logs: docker logs ${containerName}`;
583
+
499
584
  return Promise.resolve({
500
585
  success: true,
501
586
  containerName,
502
587
  containerId,
503
- message: `Command started in detached docker container: ${containerName}\nContainer ID: ${containerId.substring(0, 12)}\nAttach with: docker attach ${containerName}\nView logs: docker logs ${containerName}`,
588
+ message,
504
589
  });
505
590
  } else {
506
591
  // Attached mode: docker run -it --name <name> <image> <shell> -c '<command>'
@@ -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']);
@@ -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
+ });