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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # start-command
2
2
 
3
+ ## 0.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3d7fe09: Clean up Docker isolation containers by default after completion, preserve host log files, and add explicit `--keep-container`, `--always-cleanup-container`, and `--keep-container-on-fail` cleanup policy flags.
8
+
9
+ ## 0.29.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 8518034: Record the docker image-preparation phase in the session log (issue #138). When a `--isolated docker` run needs to `docker pull` an image, the pull output is now teed into the session-log file (`/tmp/start-command/logs/isolation/docker/<uuid>.log`) in real time and bracketed with `Preparing image <name>…` / `Image ready (<duration>)` markers (or `Image preparation failed` on error). Previously the minutes spent pulling a (potentially multi-GB) image left no trace in the log, so operators tailing `$ --upload-log <uuid>` during startup saw only the header. The single session-log file is now a gap-free record of the run, including the longest, most failure-prone phase.
14
+
3
15
  ## 0.29.1
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -503,6 +503,10 @@ async function runWithIsolation(
503
503
  endpoint: options.endpoint,
504
504
  user: options.user,
505
505
  keepAlive: options.keepAlive,
506
+ autoRemoveDockerContainer: options.autoRemoveDockerContainer,
507
+ alwaysCleanupContainer: options.alwaysCleanupContainer,
508
+ keepContainer: options.keepContainer,
509
+ keepContainerOnFail: options.keepContainerOnFail,
506
510
  useCommandStream,
507
511
  },
508
512
  });
@@ -574,6 +578,9 @@ async function runWithIsolation(
574
578
  user: createdUser,
575
579
  keepAlive: options.keepAlive,
576
580
  autoRemoveDockerContainer: options.autoRemoveDockerContainer,
581
+ alwaysCleanupContainer: options.alwaysCleanupContainer,
582
+ keepContainer: options.keepContainer,
583
+ keepContainerOnFail: options.keepContainerOnFail,
577
584
  volumes: options.volumes,
578
585
  mounts: options.mounts,
579
586
  env: options.env,
@@ -19,7 +19,10 @@
19
19
  * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
20
20
  * --keep-user Keep isolated user after command completes (don't delete)
21
21
  * --keep-alive, -k Keep isolation environment alive after command exits
22
- * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
22
+ * --auto-remove-docker-container Always remove docker container after exit (compatibility alias)
23
+ * --always-cleanup-container Always remove docker container after exit (default)
24
+ * --keep-container Keep docker container filesystem after exit
25
+ * --keep-container-on-fail Remove successful docker containers, keep failed ones
23
26
  * --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
24
27
  * --use-command-stream Use command-stream library for command execution (experimental)
25
28
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
@@ -181,7 +184,10 @@ function parseArgs(args) {
181
184
  userName: null, // Optional custom username for isolated user
182
185
  keepUser: false, // Keep isolated user after command completes (don't delete)
183
186
  keepAlive: false, // Keep environment alive after command exits
184
- autoRemoveDockerContainer: false, // Auto-remove docker container after exit
187
+ autoRemoveDockerContainer: false, // Always remove docker container after exit (compatibility alias)
188
+ alwaysCleanupContainer: false, // Explicitly request default always-cleanup docker policy
189
+ keepContainer: false, // Keep docker container filesystem after exit
190
+ keepContainerOnFail: false, // Keep docker container filesystem only when command fails
185
191
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
186
192
  useCommandStream: false, // Use command-stream library for command execution
187
193
  status: null, // UUID to show status for
@@ -449,6 +455,24 @@ function parseOption(args, index, options) {
449
455
  return 1;
450
456
  }
451
457
 
458
+ // --always-cleanup-container
459
+ if (arg === '--always-cleanup-container') {
460
+ options.alwaysCleanupContainer = true;
461
+ return 1;
462
+ }
463
+
464
+ // --keep-container
465
+ if (arg === '--keep-container') {
466
+ options.keepContainer = true;
467
+ return 1;
468
+ }
469
+
470
+ // --keep-container-on-fail
471
+ if (arg === '--keep-container-on-fail') {
472
+ options.keepContainerOnFail = true;
473
+ return 1;
474
+ }
475
+
452
476
  // --shell <shell>
453
477
  if (arg === '--shell') {
454
478
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
@@ -649,6 +673,35 @@ function validateDockerRuntimeOptionsRequireDocker(options) {
649
673
  }
650
674
  }
651
675
 
676
+ function validateDockerCleanupOptions(options, hasDocker) {
677
+ const cleanupFlags = [
678
+ ['--auto-remove-docker-container', options.autoRemoveDockerContainer],
679
+ ['--always-cleanup-container', options.alwaysCleanupContainer],
680
+ ['--keep-container', options.keepContainer],
681
+ ['--keep-container-on-fail', options.keepContainerOnFail],
682
+ ];
683
+
684
+ for (const [flag, enabled] of cleanupFlags) {
685
+ if (enabled && !hasDocker) {
686
+ throw new Error(
687
+ `${flag} option is only valid when isolation stack includes docker`
688
+ );
689
+ }
690
+ }
691
+
692
+ const selectedPolicies = [
693
+ options.autoRemoveDockerContainer || options.alwaysCleanupContainer,
694
+ options.keepContainer,
695
+ options.keepContainerOnFail,
696
+ ].filter(Boolean).length;
697
+
698
+ if (selectedPolicies > 1) {
699
+ throw new Error(
700
+ 'Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail.'
701
+ );
702
+ }
703
+ }
704
+
652
705
  /**
653
706
  * Validate parsed options
654
707
  * @param {object} options - Parsed options
@@ -764,12 +817,7 @@ function validateOptions(options) {
764
817
  );
765
818
  }
766
819
 
767
- // Auto-remove-docker-container is only valid with docker in stack
768
- if (options.autoRemoveDockerContainer && !stack.includes('docker')) {
769
- throw new Error(
770
- '--auto-remove-docker-container option is only valid when isolation stack includes docker'
771
- );
772
- }
820
+ validateDockerCleanupOptions(options, stack.includes('docker'));
773
821
 
774
822
  // Docker runtime options (--volume, --mount, --env, --privileged) require docker
775
823
  if (!stack.includes('docker')) {
@@ -785,11 +833,7 @@ function validateOptions(options) {
785
833
  }
786
834
  } else {
787
835
  // Validate options that require isolation when no isolation is specified
788
- if (options.autoRemoveDockerContainer) {
789
- throw new Error(
790
- '--auto-remove-docker-container option is only valid when isolation stack includes docker'
791
- );
792
- }
836
+ validateDockerCleanupOptions(options, false);
793
837
  if (options.image) {
794
838
  throw new Error(
795
839
  '--image option is only valid when isolation stack includes docker'
@@ -84,8 +84,19 @@ function buildNextLevelCommand(options, command) {
84
84
  parts.push(`--session-id ${options.sessionId}`);
85
85
  }
86
86
 
87
- if (options.autoRemoveDockerContainer) {
88
- parts.push('--auto-remove-docker-container');
87
+ if (remainingStack.includes('docker')) {
88
+ if (options.autoRemoveDockerContainer) {
89
+ parts.push('--auto-remove-docker-container');
90
+ }
91
+ if (options.alwaysCleanupContainer) {
92
+ parts.push('--always-cleanup-container');
93
+ }
94
+ if (options.keepContainer) {
95
+ parts.push('--keep-container');
96
+ }
97
+ if (options.keepContainerOnFail) {
98
+ parts.push('--keep-container-on-fail');
99
+ }
89
100
  }
90
101
 
91
102
  if (options.shell && options.shell !== 'auto') {
@@ -0,0 +1,145 @@
1
+ const { spawn, spawnSync } = require('child_process');
2
+ const {
3
+ appendLogFile,
4
+ createShellLogFooterSnippet,
5
+ shellQuote,
6
+ } = require('./isolation-log-utils');
7
+
8
+ const DOCKER_CONTAINER_CLEANUP_POLICY = {
9
+ ALWAYS: 'always',
10
+ KEEP: 'keep',
11
+ KEEP_ON_FAIL: 'keep-on-fail',
12
+ };
13
+
14
+ function getDockerContainerCleanupPolicy(options = {}) {
15
+ if (options.keepContainer) {
16
+ return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP;
17
+ }
18
+ if (options.keepContainerOnFail) {
19
+ return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL;
20
+ }
21
+ return DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS;
22
+ }
23
+
24
+ function shouldCleanupDockerContainer(policy, exitCode) {
25
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
26
+ return true;
27
+ }
28
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
29
+ return exitCode === 0;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function getDockerContainerCleanupInstructions(containerName) {
35
+ return [
36
+ `Container kept for investigation: ${containerName}`,
37
+ `Inspect: docker exec -it ${containerName} sh (if running) or docker start -ai ${containerName}`,
38
+ `Remove when done: docker rm -f ${containerName}`,
39
+ ].join('\n');
40
+ }
41
+
42
+ function appendDockerContainerCleanupPolicyMessage(
43
+ message,
44
+ containerName,
45
+ policy
46
+ ) {
47
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
48
+ return `${message}\n${getDockerContainerCleanupInstructions(containerName)}`;
49
+ }
50
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
51
+ return (
52
+ `${message}\nContainer will be removed after successful completion.` +
53
+ `\nContainer will be kept if the command fails.` +
54
+ `\nRemove when done: docker rm -f ${containerName}`
55
+ );
56
+ }
57
+ return `${message}\nContainer will be removed after command completes.`;
58
+ }
59
+
60
+ function removeDockerContainer(containerName, logPath = null) {
61
+ const result = spawnSync('docker', ['rm', '-f', containerName], {
62
+ encoding: 'utf8',
63
+ stdio: ['pipe', 'pipe', 'pipe'],
64
+ });
65
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
66
+ if (logPath && output) {
67
+ appendLogFile(logPath, output.endsWith('\n') ? output : `${output}\n`);
68
+ }
69
+ return !result.error && result.status === 0;
70
+ }
71
+
72
+ function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
73
+ const quotedName = shellQuote(containerName);
74
+ const parts = [];
75
+
76
+ if (logPath) {
77
+ const quotedLogPath = shellQuote(logPath);
78
+ parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
79
+ parts.push(
80
+ `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
81
+ );
82
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
83
+ parts.push(`docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true`);
84
+ } else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
85
+ parts.push(
86
+ `if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; fi`
87
+ );
88
+ }
89
+ parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
90
+ } else {
91
+ parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
92
+ parts.push(
93
+ `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
94
+ );
95
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
96
+ parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
97
+ } else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
98
+ parts.push(
99
+ `if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
100
+ );
101
+ }
102
+ }
103
+
104
+ return parts.join('; ');
105
+ }
106
+
107
+ function startDetachedDockerCompletionWatcher(containerName, policy, logPath) {
108
+ const watcher = spawn(
109
+ 'sh',
110
+ ['-c', buildDetachedDockerCompletionScript(containerName, policy, logPath)],
111
+ {
112
+ detached: true,
113
+ stdio: 'ignore',
114
+ }
115
+ );
116
+ watcher.unref();
117
+ }
118
+
119
+ function spawnAttachedDocker(dockerArgs, logPath) {
120
+ if (!logPath) {
121
+ return spawn('docker', dockerArgs, { stdio: 'inherit' });
122
+ }
123
+
124
+ const child = spawn('docker', dockerArgs, {
125
+ stdio: ['inherit', 'pipe', 'pipe'],
126
+ });
127
+ const tee = (chunk, stream) => {
128
+ stream.write(chunk);
129
+ appendLogFile(logPath, chunk.toString());
130
+ };
131
+ child.stdout.on('data', (chunk) => tee(chunk, process.stdout));
132
+ child.stderr.on('data', (chunk) => tee(chunk, process.stderr));
133
+ return child;
134
+ }
135
+
136
+ module.exports = {
137
+ DOCKER_CONTAINER_CLEANUP_POLICY,
138
+ getDockerContainerCleanupPolicy,
139
+ shouldCleanupDockerContainer,
140
+ getDockerContainerCleanupInstructions,
141
+ appendDockerContainerCleanupPolicyMessage,
142
+ removeDockerContainer,
143
+ startDetachedDockerCompletionWatcher,
144
+ spawnAttachedDocker,
145
+ };
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
10
12
  const { execSync, spawnSync } = require('child_process');
11
13
 
12
14
  /**
@@ -102,31 +104,141 @@ function dockerImageExists(image) {
102
104
  }
103
105
  }
104
106
 
107
+ /**
108
+ * Run `docker pull <image>` while teeing its output to the session log file.
109
+ *
110
+ * When a logPath is given (and tee is available, i.e. non-Windows), the pull
111
+ * output is streamed to BOTH the console and the log file in real time so the
112
+ * image-preparation phase is captured in the single session log (issue #138).
113
+ * docker's own exit code is recovered via a sentinel status file because the
114
+ * exit status of a `cmd | tee` pipeline reflects tee, not docker.
115
+ *
116
+ * Without a logPath, it falls back to the previous behavior: inherited stdio
117
+ * for real-time console output, with no log capture. On Windows (no portable
118
+ * shell `tee`) but with a logPath, the pull output is captured, echoed to the
119
+ * console, and appended to the log after the pull so the session log still
120
+ * records the image-preparation phase (issue #138) — just not streamed live.
121
+ *
122
+ * @param {string} image - Docker image to pull
123
+ * @param {string|null} logPath - Session log file to append pull output to
124
+ * @returns {{status: number, error?: Error}} Spawn result (status = docker exit code)
125
+ */
126
+ function runDockerPull(image, logPath) {
127
+ const { shellQuote } = require('./isolation-log-utils');
128
+
129
+ // Without a log target, keep the original inherited-stdio behavior for
130
+ // fancy real-time console output.
131
+ if (!logPath) {
132
+ return spawnSync('docker', ['pull', image], {
133
+ stdio: ['pipe', 'inherit', 'inherit'],
134
+ });
135
+ }
136
+
137
+ // Windows has no portable shell `tee`, so capture the pull output, echo it to
138
+ // the console, and append it to the log so the image-preparation phase is
139
+ // still recorded in the session log (issue #138), just not streamed live.
140
+ if (process.platform === 'win32') {
141
+ const { appendLogFile } = require('./isolation-log-utils');
142
+ const result = spawnSync('docker', ['pull', image], {
143
+ stdio: ['pipe', 'pipe', 'pipe'],
144
+ encoding: 'utf8',
145
+ });
146
+ const combined = `${result.stdout || ''}${result.stderr || ''}`;
147
+ if (combined) {
148
+ process.stdout.write(combined);
149
+ try {
150
+ appendLogFile(
151
+ logPath,
152
+ combined.endsWith('\n') ? combined : `${combined}\n`
153
+ );
154
+ } catch {
155
+ // best-effort: log capture must never break the pull
156
+ }
157
+ }
158
+ return result;
159
+ }
160
+
161
+ // Tee docker pull output to both the console and the log file. docker writes
162
+ // to a pipe here, so it emits plain progress lines (ideal for a log). The
163
+ // sentinel file captures docker's real exit code (the pipeline's own exit
164
+ // status would be tee's).
165
+ const statusFile = path.join(
166
+ os.tmpdir(),
167
+ `start-docker-pull-${process.pid}-${Date.now()}.status`
168
+ );
169
+ try {
170
+ const pipeline =
171
+ `{ docker pull ${shellQuote(image)} 2>&1; echo $? > ${shellQuote(statusFile)}; } ` +
172
+ `| tee -a ${shellQuote(logPath)}`;
173
+ const result = spawnSync('sh', ['-c', pipeline], {
174
+ stdio: ['pipe', 'inherit', 'inherit'],
175
+ });
176
+ if (result.error) {
177
+ return result;
178
+ }
179
+ let status = result.status;
180
+ try {
181
+ const recorded = fs.readFileSync(statusFile, 'utf8').trim();
182
+ if (recorded !== '') {
183
+ status = parseInt(recorded, 10);
184
+ }
185
+ } catch {
186
+ // Sentinel missing (e.g. sh/tee failed before docker ran); keep pipeline status.
187
+ }
188
+ return { ...result, status };
189
+ } finally {
190
+ try {
191
+ fs.unlinkSync(statusFile);
192
+ } catch {
193
+ // best-effort cleanup
194
+ }
195
+ }
196
+ }
197
+
105
198
  /**
106
199
  * Pull a Docker image with output streaming
107
- * Displays the pull operation as a virtual command in the timeline
200
+ * Displays the pull operation as a virtual command in the timeline.
201
+ *
202
+ * When a logPath is provided, the image-preparation phase (the `docker pull`)
203
+ * is also recorded in the session log so the single log file is a gap-free
204
+ * record of everything that ran (issue #138): a `Preparing image …` marker with
205
+ * a timestamp is written before the pull, the pull output is teed into the log,
206
+ * and an `Image ready (<duration>)` marker is written afterwards.
207
+ *
108
208
  * @param {string} image - Docker image to pull
209
+ * @param {string|null} logPath - Optional session log file to append output to
109
210
  * @returns {{success: boolean, output: string}} Pull result
110
211
  */
111
- function dockerPullImage(image) {
212
+ function dockerPullImage(image, logPath = null) {
112
213
  const {
113
214
  createVirtualCommandBlock,
114
215
  createVirtualCommandResult,
115
216
  createTimelineSeparator,
116
217
  } = require('./output-blocks');
218
+ const { appendLogFile, getTimestamp } = require('./isolation-log-utils');
117
219
 
118
220
  // Print the virtual command line followed by empty line for visual separation
119
221
  console.log(createVirtualCommandBlock(`docker pull ${image}`));
120
222
  console.log();
121
223
 
224
+ // Record the start of the image-preparation phase in the session log so
225
+ // operators tailing the log see progress instead of a header-only file.
226
+ const prepStartMs = Date.now();
227
+ if (logPath) {
228
+ appendLogFile(
229
+ logPath,
230
+ `$ docker pull ${image}\nPreparing image ${image}… (${getTimestamp()})\n`
231
+ );
232
+ }
233
+
122
234
  let output = '';
123
235
  let success;
124
236
 
125
237
  try {
126
- // Run docker pull with inherited stdio for real-time output
127
- const result = spawnSync('docker', ['pull', image], {
128
- stdio: ['pipe', 'inherit', 'inherit'],
129
- });
238
+ const result = runDockerPull(image, logPath);
239
+ if (result.error) {
240
+ throw result.error;
241
+ }
130
242
 
131
243
  success = result.status === 0;
132
244
 
@@ -140,6 +252,21 @@ function dockerPullImage(image) {
140
252
  console.error(`Failed to run docker pull: ${err.message}`);
141
253
  output = err.message;
142
254
  success = false;
255
+ if (logPath) {
256
+ appendLogFile(logPath, `Failed to run docker pull: ${err.message}\n`);
257
+ }
258
+ }
259
+
260
+ // Record the end of the image-preparation phase with elapsed duration so the
261
+ // prep time is visible even when full progress is unavailable (issue #138).
262
+ if (logPath) {
263
+ const durationSec = ((Date.now() - prepStartMs) / 1000).toFixed(1);
264
+ appendLogFile(
265
+ logPath,
266
+ success
267
+ ? `Image ready (${durationSec}s)\n`
268
+ : `Image preparation failed (${durationSec}s)\n`
269
+ );
143
270
  }
144
271
 
145
272
  // Print empty line before result marker for visual separation (issue #73)
@@ -18,10 +18,19 @@ const {
18
18
  } = require('./screen-isolation');
19
19
  const {
20
20
  appendLogFile,
21
- createShellLogFooterSnippet,
22
21
  shellQuote,
23
22
  wrapCommandWithLogFooter,
24
23
  } = require('./isolation-log-utils');
24
+ const {
25
+ DOCKER_CONTAINER_CLEANUP_POLICY,
26
+ getDockerContainerCleanupPolicy,
27
+ shouldCleanupDockerContainer,
28
+ getDockerContainerCleanupInstructions,
29
+ appendDockerContainerCleanupPolicyMessage,
30
+ removeDockerContainer,
31
+ startDetachedDockerCompletionWatcher,
32
+ spawnAttachedDocker,
33
+ } = require('./docker-cleanup');
25
34
 
26
35
  /**
27
36
  * Check if a command is available on the system
@@ -554,7 +563,7 @@ function buildDockerRuntimeMetadata(options = {}) {
554
563
  /**
555
564
  * Run command in Docker container
556
565
  * @param {string} command - Command to execute
557
- * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer, volumes, mounts, env, privileged)
566
+ * @param {object} options - Options (image, session/name, detached, user, keepAlive, container cleanup policy, volumes, mounts, env, privileged)
558
567
  * @returns {Promise<{success: boolean, containerName: string, message: string}>}
559
568
  */
560
569
  function runInDocker(command, options = {}) {
@@ -587,8 +596,11 @@ function runInDocker(command, options = {}) {
587
596
  }
588
597
 
589
598
  const containerName = options.session || generateSessionName('docker');
599
+ const cleanupPolicy = getDockerContainerCleanupPolicy(options);
590
600
  if (!dockerImageExists(options.image)) {
591
- const pullResult = dockerPullImage(options.image);
601
+ // Pass logPath so the image-preparation phase (docker pull) is recorded in
602
+ // the session log, keeping it a gap-free record of the run (issue #138).
603
+ const pullResult = dockerPullImage(options.image, options.logPath);
592
604
  if (!pullResult.success) {
593
605
  return Promise.resolve({
594
606
  success: false,
@@ -615,9 +627,6 @@ function runInDocker(command, options = {}) {
615
627
  dockerArgs.push('-i', '-t');
616
628
  }
617
629
  dockerArgs.push('--name', containerName);
618
- if (options.autoRemoveDockerContainer) {
619
- dockerArgs.push('--rm');
620
- }
621
630
 
622
631
  if (options.user) {
623
632
  dockerArgs.push('--user', options.user);
@@ -645,6 +654,7 @@ function runInDocker(command, options = {}) {
645
654
  console.log(
646
655
  `[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
647
656
  );
657
+ console.log(`[DEBUG] docker cleanup policy: ${cleanupPolicy}`);
648
658
  }
649
659
 
650
660
  const dockerResult = spawnSync('docker', dockerArgs, {
@@ -665,18 +675,11 @@ function runInDocker(command, options = {}) {
665
675
 
666
676
  const containerId = dockerResult.stdout.trim();
667
677
 
668
- if (options.logPath) {
669
- const loggerScript = [
670
- `docker logs -f ${shellQuote(containerName)} >> ${shellQuote(options.logPath)} 2>&1`,
671
- `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${shellQuote(containerName)} 2>/dev/null || printf '%s' '-1')`,
672
- `${createShellLogFooterSnippet()} >> ${shellQuote(options.logPath)}`,
673
- ].join('; ');
674
- const logger = spawn('sh', ['-c', loggerScript], {
675
- detached: true,
676
- stdio: 'ignore',
677
- });
678
- logger.unref();
679
- }
678
+ startDetachedDockerCompletionWatcher(
679
+ containerName,
680
+ cleanupPolicy,
681
+ options.logPath
682
+ );
680
683
 
681
684
  let message = `Command started in detached docker container: ${containerName}`;
682
685
  message += `\nContainer ID: ${containerId.substring(0, 12)}`;
@@ -685,11 +688,11 @@ function runInDocker(command, options = {}) {
685
688
  } else {
686
689
  message += `\nContainer will exit automatically after command completes.`;
687
690
  }
688
- if (options.autoRemoveDockerContainer) {
689
- message += `\nContainer will be automatically removed after exit.`;
690
- } else {
691
- message += `\nContainer filesystem will be preserved after exit.`;
692
- }
691
+ message = appendDockerContainerCleanupPolicyMessage(
692
+ message,
693
+ containerName,
694
+ cleanupPolicy
695
+ );
693
696
  message += `\nAttach with: docker attach ${containerName}`;
694
697
  message += `\nView logs: docker logs ${containerName}`;
695
698
  if (options.logPath) {
@@ -703,7 +706,9 @@ function runInDocker(command, options = {}) {
703
706
  message,
704
707
  });
705
708
  } else {
706
- const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
709
+ const dockerArgs = ['run'];
710
+ dockerArgs.push(hasTTY() ? '-it' : '-i');
711
+ dockerArgs.push('--name', containerName);
707
712
  if (options.user) {
708
713
  dockerArgs.push('--user', options.user);
709
714
  }
@@ -737,13 +742,14 @@ function runInDocker(command, options = {}) {
737
742
 
738
743
  return new Promise((resolve) => {
739
744
  const startTime = Date.now();
740
- const child = spawn('docker', dockerArgs, { stdio: 'inherit' });
745
+ const child = spawnAttachedDocker(dockerArgs, options.logPath);
741
746
 
742
747
  child.on('exit', (code) => {
743
748
  const durationMs = Date.now() - startTime;
744
- let message = `Docker container "${containerName}" exited with code ${code}`;
749
+ const exitCode = code ?? 1;
750
+ let message = `Docker container "${containerName}" exited with code ${exitCode}`;
745
751
  // Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
746
- if (isBareShell && code !== 0 && durationMs < 3000) {
752
+ if (isBareShell && exitCode !== 0 && durationMs < 3000) {
747
753
  const shell0 = command.trim().split(/\s+/)[0];
748
754
  // prettier-ignore
749
755
  const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
@@ -751,11 +757,28 @@ function runInDocker(command, options = {}) {
751
757
  console.log(hint);
752
758
  message += `\n${hint}`;
753
759
  }
760
+
761
+ if (shouldCleanupDockerContainer(cleanupPolicy, exitCode)) {
762
+ if (removeDockerContainer(containerName, options.logPath)) {
763
+ message += `\nContainer removed after completion.`;
764
+ } else {
765
+ message += `\nWarning: failed to remove container automatically.`;
766
+ message += `\nRemove when done: docker rm -f ${containerName}`;
767
+ }
768
+ } else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
769
+ message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
770
+ } else if (
771
+ cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
772
+ ) {
773
+ message += `\nContainer kept because the command failed.`;
774
+ message += `\nRemove when done: docker rm -f ${containerName}`;
775
+ }
776
+
754
777
  resolve({
755
- success: code === 0,
778
+ success: exitCode === 0,
756
779
  containerName,
757
780
  message,
758
- exitCode: code,
781
+ exitCode,
759
782
  });
760
783
  });
761
784
 
package/src/lib/usage.js CHANGED
@@ -18,7 +18,10 @@ Options:
18
18
  --isolated-user, -u [name] Create isolated user with same permissions
19
19
  --keep-user Keep isolated user after command completes
20
20
  --keep-alive, -k Keep isolation environment alive after command exits
21
- --auto-remove-docker-container Auto-remove docker container after exit
21
+ --auto-remove-docker-container Always remove docker container after exit (compatibility alias)
22
+ --always-cleanup-container Always remove docker container after exit (default)
23
+ --keep-container Keep docker container filesystem after exit
24
+ --keep-container-on-fail Remove successful docker containers, keep failed ones
22
25
  --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
23
26
  --use-command-stream Use command-stream library for execution (experimental)
24
27
  --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)