start-command 0.29.1 → 0.30.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,143 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('assert');
5
+ const { parseArgs } = require('../src/lib/args-parser');
6
+
7
+ function parseDocker(args = []) {
8
+ return parseArgs([
9
+ '--isolated',
10
+ 'docker',
11
+ '--image',
12
+ 'alpine',
13
+ ...args,
14
+ '--',
15
+ 'npm',
16
+ 'test',
17
+ ]);
18
+ }
19
+
20
+ describe('docker container cleanup options', () => {
21
+ it('should parse --auto-remove-docker-container flag', () => {
22
+ const result = parseDocker(['--auto-remove-docker-container']);
23
+ assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
24
+ });
25
+
26
+ it('should parse --always-cleanup-container flag', () => {
27
+ const result = parseDocker(['--always-cleanup-container']);
28
+ assert.strictEqual(result.wrapperOptions.alwaysCleanupContainer, true);
29
+ });
30
+
31
+ it('should parse --keep-container flag', () => {
32
+ const result = parseDocker(['--keep-container']);
33
+ assert.strictEqual(result.wrapperOptions.keepContainer, true);
34
+ });
35
+
36
+ it('should parse --keep-container-on-fail flag', () => {
37
+ const result = parseDocker(['--keep-container-on-fail']);
38
+ assert.strictEqual(result.wrapperOptions.keepContainerOnFail, true);
39
+ });
40
+
41
+ it('should default cleanup flags to false', () => {
42
+ const result = parseDocker();
43
+ assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, false);
44
+ assert.strictEqual(result.wrapperOptions.alwaysCleanupContainer, false);
45
+ assert.strictEqual(result.wrapperOptions.keepContainer, false);
46
+ assert.strictEqual(result.wrapperOptions.keepContainerOnFail, false);
47
+ });
48
+
49
+ it('should throw error for docker cleanup options without docker isolation', () => {
50
+ assert.throws(() => {
51
+ parseArgs([
52
+ '-i',
53
+ 'tmux',
54
+ '--auto-remove-docker-container',
55
+ '--',
56
+ 'npm',
57
+ 'test',
58
+ ]);
59
+ }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
60
+ assert.throws(() => {
61
+ parseArgs([
62
+ '-i',
63
+ 'tmux',
64
+ '--always-cleanup-container',
65
+ '--',
66
+ 'npm',
67
+ 'test',
68
+ ]);
69
+ }, /--always-cleanup-container option is only valid when isolation stack includes docker/);
70
+ assert.throws(() => {
71
+ parseArgs(['-i', 'tmux', '--keep-container', '--', 'npm', 'test']);
72
+ }, /--keep-container option is only valid when isolation stack includes docker/);
73
+ assert.throws(() => {
74
+ parseArgs([
75
+ '-i',
76
+ 'tmux',
77
+ '--keep-container-on-fail',
78
+ '--',
79
+ 'npm',
80
+ 'test',
81
+ ]);
82
+ }, /--keep-container-on-fail option is only valid when isolation stack includes docker/);
83
+ });
84
+
85
+ it('should throw error for docker cleanup options without isolation', () => {
86
+ assert.throws(() => {
87
+ parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
88
+ }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
89
+ assert.throws(() => {
90
+ parseArgs(['--always-cleanup-container', '--', 'npm', 'test']);
91
+ }, /--always-cleanup-container option is only valid when isolation stack includes docker/);
92
+ assert.throws(() => {
93
+ parseArgs(['--keep-container', '--', 'npm', 'test']);
94
+ }, /--keep-container option is only valid when isolation stack includes docker/);
95
+ assert.throws(() => {
96
+ parseArgs(['--keep-container-on-fail', '--', 'npm', 'test']);
97
+ }, /--keep-container-on-fail option is only valid when isolation stack includes docker/);
98
+ });
99
+
100
+ it('should work with keep-alive and auto-remove-docker-container', () => {
101
+ const result = parseArgs([
102
+ '-i',
103
+ 'docker',
104
+ '--image',
105
+ 'node:20',
106
+ '-k',
107
+ '--auto-remove-docker-container',
108
+ '--',
109
+ 'npm',
110
+ 'test',
111
+ ]);
112
+ assert.strictEqual(result.wrapperOptions.isolated, 'docker');
113
+ assert.strictEqual(result.wrapperOptions.image, 'node:20');
114
+ assert.strictEqual(result.wrapperOptions.keepAlive, true);
115
+ assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
116
+ });
117
+
118
+ it('should reject conflicting docker cleanup options', () => {
119
+ assert.throws(() => {
120
+ parseArgs([
121
+ '-i',
122
+ 'docker',
123
+ '--keep-container',
124
+ '--always-cleanup-container',
125
+ '--',
126
+ 'npm',
127
+ 'test',
128
+ ]);
129
+ }, /Cannot combine docker container cleanup policies/);
130
+
131
+ assert.throws(() => {
132
+ parseArgs([
133
+ '-i',
134
+ 'docker',
135
+ '--keep-container',
136
+ '--keep-container-on-fail',
137
+ '--',
138
+ 'npm',
139
+ 'test',
140
+ ]);
141
+ }, /Cannot combine docker container cleanup policies/);
142
+ });
143
+ });
@@ -343,75 +343,6 @@ describe('parseArgs', () => {
343
343
  });
344
344
  });
345
345
 
346
- describe('auto-remove-docker-container option', () => {
347
- it('should parse --auto-remove-docker-container flag', () => {
348
- const result = parseArgs([
349
- '--isolated',
350
- 'docker',
351
- '--image',
352
- 'alpine',
353
- '--auto-remove-docker-container',
354
- '--',
355
- 'npm',
356
- 'test',
357
- ]);
358
- assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
359
- });
360
-
361
- it('should default autoRemoveDockerContainer to false', () => {
362
- const result = parseArgs([
363
- '-i',
364
- 'docker',
365
- '--image',
366
- 'alpine',
367
- '--',
368
- 'npm',
369
- 'test',
370
- ]);
371
- assert.strictEqual(
372
- result.wrapperOptions.autoRemoveDockerContainer,
373
- false
374
- );
375
- });
376
-
377
- it('should throw error for auto-remove-docker-container without docker isolation', () => {
378
- assert.throws(() => {
379
- parseArgs([
380
- '-i',
381
- 'tmux',
382
- '--auto-remove-docker-container',
383
- '--',
384
- 'npm',
385
- 'test',
386
- ]);
387
- }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
388
- });
389
-
390
- it('should throw error for auto-remove-docker-container without isolation', () => {
391
- assert.throws(() => {
392
- parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
393
- }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
394
- });
395
-
396
- it('should work with keep-alive and auto-remove-docker-container', () => {
397
- const result = parseArgs([
398
- '-i',
399
- 'docker',
400
- '--image',
401
- 'node:20',
402
- '-k',
403
- '--auto-remove-docker-container',
404
- '--',
405
- 'npm',
406
- 'test',
407
- ]);
408
- assert.strictEqual(result.wrapperOptions.isolated, 'docker');
409
- assert.strictEqual(result.wrapperOptions.image, 'node:20');
410
- assert.strictEqual(result.wrapperOptions.keepAlive, true);
411
- assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
412
- });
413
- });
414
-
415
346
  describe('command without separator', () => {
416
347
  it('should parse command after options without separator', () => {
417
348
  const result = parseArgs(['-i', 'tmux', '-d', 'npm', 'start']);
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('assert');
5
+ const { buildNextLevelCommand } = require('../src/lib/command-builder');
6
+
7
+ describe('command-builder docker cleanup forwarding', () => {
8
+ it('forwards cleanup policy flags when a remaining level uses docker', () => {
9
+ const command = buildNextLevelCommand(
10
+ {
11
+ isolatedStack: ['tmux', 'docker'],
12
+ imageStack: [null, 'alpine'],
13
+ keepContainerOnFail: true,
14
+ },
15
+ 'npm test'
16
+ );
17
+
18
+ assert.match(command, /--isolated "docker"/);
19
+ assert.match(command, /--keep-container-on-fail/);
20
+ });
21
+
22
+ it('does not forward docker cleanup flags after the docker level is consumed', () => {
23
+ const command = buildNextLevelCommand(
24
+ {
25
+ isolatedStack: ['docker', 'tmux'],
26
+ imageStack: ['alpine', null],
27
+ keepContainer: true,
28
+ },
29
+ 'npm test'
30
+ );
31
+
32
+ assert.match(command, /--isolated "tmux"/);
33
+ assert.doesNotMatch(command, /--keep-container/);
34
+ });
35
+ });
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Tests for Docker auto-remove container feature
3
+ * Tests for Docker container cleanup behavior
4
4
  */
5
5
 
6
6
  const { describe, it } = require('node:test');
7
7
  const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
8
11
  const { isCommandAvailable } = require('../src/lib/isolation');
9
12
  const { runInDocker } = require('../src/lib/isolation');
10
13
  const { execSync } = require('child_process');
@@ -26,9 +29,9 @@ async function waitFor(conditionFn, timeout = 5000, interval = 100) {
26
29
  const { canRunLinuxDockerImages } = require('../src/lib/isolation');
27
30
  const DOCKER_TEST_TIMEOUT = 20000;
28
31
 
29
- describe('Docker Auto-Remove Container Feature', () => {
30
- // These tests verify the --auto-remove-docker-container option
31
- // which automatically removes the container after exit (disabled by default)
32
+ describe('Docker Container Cleanup Policy', () => {
33
+ // These tests verify that docker isolation removes finished containers by
34
+ // default while still providing explicit flags to keep them for investigation.
32
35
 
33
36
  describe('auto-remove enabled', () => {
34
37
  it(
@@ -55,8 +58,8 @@ describe('Docker Auto-Remove Container Feature', () => {
55
58
 
56
59
  assert.strictEqual(result.success, true);
57
60
  assert.ok(
58
- result.message.includes('automatically removed'),
59
- 'Message should indicate auto-removal'
61
+ result.message.includes('will be removed'),
62
+ 'Message should indicate cleanup'
60
63
  );
61
64
 
62
65
  // Wait for container to finish and be removed
@@ -99,9 +102,9 @@ describe('Docker Auto-Remove Container Feature', () => {
99
102
  );
100
103
  });
101
104
 
102
- describe('auto-remove disabled (default)', () => {
105
+ describe('default cleanup', () => {
103
106
  it(
104
- 'should preserve container filesystem by default (without autoRemoveDockerContainer)',
107
+ 'should remove container filesystem by default',
105
108
  { timeout: DOCKER_TEST_TIMEOUT },
106
109
  async () => {
107
110
  if (!canRunLinuxDockerImages()) {
@@ -111,9 +114,10 @@ describe('Docker Auto-Remove Container Feature', () => {
111
114
  return;
112
115
  }
113
116
 
114
- const containerName = `test-preserve-${Date.now()}`;
117
+ const containerName = `test-default-cleanup-${Date.now()}`;
115
118
 
116
- // Run command without autoRemoveDockerContainer
119
+ // Run command without any cleanup flag. The default should still remove
120
+ // the container after the command finishes.
117
121
  const result = await runInDocker('echo "test" && sleep 0.1', {
118
122
  image: 'alpine:latest',
119
123
  session: containerName,
@@ -124,12 +128,76 @@ describe('Docker Auto-Remove Container Feature', () => {
124
128
 
125
129
  assert.strictEqual(result.success, true);
126
130
  assert.ok(
127
- result.message.includes('filesystem will be preserved'),
128
- 'Message should indicate filesystem preservation'
131
+ result.message.includes('will be removed'),
132
+ 'Message should indicate default cleanup'
129
133
  );
130
134
 
131
- // Wait for container to exit
132
- await waitFor(() => {
135
+ const containerRemoved = await waitFor(() => {
136
+ try {
137
+ execSync(`docker inspect -f '{{.State.Status}}' ${containerName}`, {
138
+ encoding: 'utf8',
139
+ stdio: ['pipe', 'pipe', 'pipe'],
140
+ });
141
+ return false;
142
+ } catch {
143
+ return true;
144
+ }
145
+ }, 10000);
146
+
147
+ assert.ok(
148
+ containerRemoved,
149
+ 'Container should be removed after exit by default'
150
+ );
151
+
152
+ try {
153
+ const allContainers = execSync('docker ps -a', {
154
+ encoding: 'utf8',
155
+ stdio: ['pipe', 'pipe', 'pipe'],
156
+ });
157
+ assert.ok(
158
+ !allContainers.includes(containerName),
159
+ 'Container should NOT appear in docker ps -a after default cleanup'
160
+ );
161
+ } catch (err) {
162
+ assert.fail(`Failed to verify container cleanup: ${err.message}`);
163
+ }
164
+ }
165
+ );
166
+ });
167
+
168
+ describe('keep-container opt-out', () => {
169
+ it(
170
+ 'should preserve container filesystem when keepContainer is true',
171
+ { timeout: DOCKER_TEST_TIMEOUT },
172
+ async () => {
173
+ if (!canRunLinuxDockerImages()) {
174
+ console.log(
175
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
176
+ );
177
+ return;
178
+ }
179
+
180
+ const containerName = `test-keep-container-${Date.now()}`;
181
+
182
+ const result = await runInDocker('echo "test" && sleep 0.1', {
183
+ image: 'alpine:latest',
184
+ session: containerName,
185
+ detached: true,
186
+ keepAlive: false,
187
+ keepContainer: true,
188
+ });
189
+
190
+ assert.strictEqual(result.success, true);
191
+ assert.ok(
192
+ result.message.includes('Container kept for investigation'),
193
+ 'Message should explain that the container is kept'
194
+ );
195
+ assert.ok(
196
+ result.message.includes(`docker rm -f ${containerName}`),
197
+ 'Message should include cleanup command'
198
+ );
199
+
200
+ const containerExited = await waitFor(() => {
133
201
  try {
134
202
  const status = execSync(
135
203
  `docker inspect -f '{{.State.Status}}' ${containerName}`,
@@ -144,26 +212,66 @@ describe('Docker Auto-Remove Container Feature', () => {
144
212
  }
145
213
  }, 10000);
146
214
 
147
- // Container should still exist (in exited state)
215
+ assert.ok(containerExited, 'Container should remain in exited state');
216
+
148
217
  try {
149
- const allContainers = execSync('docker ps -a', {
150
- encoding: 'utf8',
151
- stdio: ['pipe', 'pipe', 'pipe'],
152
- });
153
- assert.ok(
154
- allContainers.includes(containerName),
155
- 'Container should appear in docker ps -a (filesystem preserved)'
156
- );
218
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
219
+ } catch {
220
+ // Ignore cleanup errors
221
+ }
222
+ }
223
+ );
224
+
225
+ it(
226
+ 'should preserve failed containers when keepContainerOnFail is true',
227
+ { timeout: DOCKER_TEST_TIMEOUT },
228
+ async () => {
229
+ if (!canRunLinuxDockerImages()) {
157
230
  console.log(
158
- ' Docker container filesystem preserved by default (can be re-entered)'
159
- );
160
- } catch (err) {
161
- assert.fail(
162
- `Failed to verify container preservation: ${err.message}`
231
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
163
232
  );
233
+ return;
164
234
  }
165
235
 
166
- // Clean up
236
+ const containerName = `test-keep-on-fail-${Date.now()}`;
237
+
238
+ const result = await runInDocker('echo "test" && exit 7', {
239
+ image: 'alpine:latest',
240
+ session: containerName,
241
+ detached: true,
242
+ keepAlive: false,
243
+ keepContainerOnFail: true,
244
+ });
245
+
246
+ assert.strictEqual(result.success, true);
247
+ assert.ok(
248
+ result.message.includes(
249
+ 'Container will be kept if the command fails'
250
+ ),
251
+ 'Message should describe failure retention'
252
+ );
253
+ assert.ok(
254
+ result.message.includes(`docker rm -f ${containerName}`),
255
+ 'Message should include cleanup command'
256
+ );
257
+
258
+ const containerExited = await waitFor(() => {
259
+ try {
260
+ const status = execSync(
261
+ `docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' ${containerName}`,
262
+ {
263
+ encoding: 'utf8',
264
+ stdio: ['pipe', 'pipe', 'pipe'],
265
+ }
266
+ ).trim();
267
+ return status === 'exited 7';
268
+ } catch {
269
+ return false;
270
+ }
271
+ }, 10000);
272
+
273
+ assert.ok(containerExited, 'Failed container should be preserved');
274
+
167
275
  try {
168
276
  execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
169
277
  } catch {
@@ -172,4 +280,71 @@ describe('Docker Auto-Remove Container Feature', () => {
172
280
  }
173
281
  );
174
282
  });
283
+
284
+ describe('attached mode logging', () => {
285
+ it(
286
+ 'should write attached docker output to the provided host log path',
287
+ { timeout: DOCKER_TEST_TIMEOUT },
288
+ async () => {
289
+ if (!canRunLinuxDockerImages()) {
290
+ console.log(
291
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
292
+ );
293
+ return;
294
+ }
295
+
296
+ const containerName = `test-attached-log-${Date.now()}`;
297
+ const logPath = path.join(
298
+ os.tmpdir(),
299
+ `start-attached-docker-${process.pid}-${Date.now()}.log`
300
+ );
301
+ fs.writeFileSync(logPath, '=== test log header ===\n');
302
+
303
+ try {
304
+ const result = await runInDocker("printf 'attached-log-line\\n'", {
305
+ image: 'alpine:latest',
306
+ session: containerName,
307
+ detached: false,
308
+ keepAlive: false,
309
+ logPath,
310
+ });
311
+
312
+ assert.strictEqual(result.success, true);
313
+ assert.ok(
314
+ result.message.includes('Container removed after completion'),
315
+ 'Attached container should be removed after completion by default'
316
+ );
317
+
318
+ const contents = fs.readFileSync(logPath, 'utf8');
319
+ assert.ok(
320
+ contents.includes('attached-log-line'),
321
+ `Attached docker output should be written to host log, got:\n${contents}`
322
+ );
323
+
324
+ const containerRemoved = await waitFor(() => {
325
+ try {
326
+ execSync(
327
+ `docker inspect -f '{{.State.Status}}' ${containerName}`,
328
+ {
329
+ encoding: 'utf8',
330
+ stdio: ['pipe', 'pipe', 'pipe'],
331
+ }
332
+ );
333
+ return false;
334
+ } catch {
335
+ return true;
336
+ }
337
+ }, 10000);
338
+ assert.ok(containerRemoved, 'Attached container should be removed');
339
+ } finally {
340
+ try {
341
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
342
+ } catch {
343
+ // Ignore cleanup errors
344
+ }
345
+ fs.rmSync(logPath, { force: true });
346
+ }
347
+ }
348
+ );
349
+ });
175
350
  });