start-command 0.29.1 → 0.29.2

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,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.29.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
3
9
  ## 0.29.1
4
10
 
5
11
  ### 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.29.2",
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": {
@@ -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)
@@ -588,7 +588,9 @@ function runInDocker(command, options = {}) {
588
588
 
589
589
  const containerName = options.session || generateSessionName('docker');
590
590
  if (!dockerImageExists(options.image)) {
591
- const pullResult = dockerPullImage(options.image);
591
+ // Pass logPath so the image-preparation phase (docker pull) is recorded in
592
+ // the session log, keeping it a gap-free record of the run (issue #138).
593
+ const pullResult = dockerPullImage(options.image, options.logPath);
592
594
  if (!pullResult.success) {
593
595
  return Promise.resolve({
594
596
  success: false,
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Regression tests for issue #138:
4
+ * "Detached docker session log omits the image-preparation phase
5
+ * (docker pull / dind boot) — $ does not preserve the full log in one file"
6
+ *
7
+ * When a command is launched with `--isolated docker`, the image-preparation
8
+ * phase (the `docker pull`) used to be printed to the console only and never
9
+ * written to the session log file. An operator tailing the session log (e.g.
10
+ * via `$ --upload-log <uuid>`) during a multi-GB pull would see only the
11
+ * header — the minutes spent pulling left no trace in the log.
12
+ *
13
+ * The fix tees the pull output into the session log and brackets it with
14
+ * `Preparing image …` / `Image ready (<duration>)` markers so the single
15
+ * session-log file is a gap-free record of everything that ran.
16
+ *
17
+ * Reference: https://github.com/link-foundation/start/issues/138
18
+ */
19
+
20
+ const { describe, it, beforeEach, afterEach } = require('node:test');
21
+ const assert = require('assert');
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+
26
+ const {
27
+ dockerPullImage,
28
+ dockerImageExists,
29
+ isDockerAvailable,
30
+ } = require('../src/lib/docker-utils');
31
+
32
+ function makeTempLog() {
33
+ const logPath = path.join(
34
+ os.tmpdir(),
35
+ `start-138-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`
36
+ );
37
+ fs.writeFileSync(logPath, '=== Start Command Log ===\n');
38
+ return logPath;
39
+ }
40
+
41
+ describe('docker pull is recorded in the session log (issue #138)', () => {
42
+ // Silence the virtual-command console output during these tests.
43
+ let originalLog;
44
+ let originalError;
45
+ beforeEach(() => {
46
+ originalLog = console.log;
47
+ originalError = console.error;
48
+ console.log = () => {};
49
+ console.error = () => {};
50
+ });
51
+ afterEach(() => {
52
+ console.log = originalLog;
53
+ console.error = originalError;
54
+ });
55
+
56
+ it('writes prep markers and the docker error into the log on a failed pull', () => {
57
+ if (!isDockerAvailable()) {
58
+ console.log = originalLog;
59
+ console.log(' Skipping: docker daemon not available');
60
+ return;
61
+ }
62
+
63
+ const logPath = makeTempLog();
64
+ try {
65
+ // An invalid reference format fails fast without any network access.
66
+ const result = dockerPullImage('invalid..badname', logPath);
67
+ const contents = fs.readFileSync(logPath, 'utf8');
68
+
69
+ assert.strictEqual(
70
+ result.success,
71
+ false,
72
+ 'pull of invalid ref must fail'
73
+ );
74
+ assert.ok(
75
+ contents.includes('Preparing image invalid..badname'),
76
+ 'log must contain the "Preparing image …" start marker'
77
+ );
78
+ assert.ok(
79
+ contents.includes('Image preparation failed'),
80
+ 'log must contain the failure marker with elapsed duration'
81
+ );
82
+ // The docker error itself (teed pull output) must be in the log, not just
83
+ // on the console — this is the core of issue #138.
84
+ assert.ok(
85
+ contents.includes('invalid reference format'),
86
+ 'log must capture the teed docker pull error output'
87
+ );
88
+ } finally {
89
+ fs.rmSync(logPath, { force: true });
90
+ }
91
+ });
92
+
93
+ it('tees real pull output and an "Image ready" marker into the log', () => {
94
+ if (!isDockerAvailable()) {
95
+ console.log = originalLog;
96
+ console.log(' Skipping: docker daemon not available');
97
+ return;
98
+ }
99
+ if (process.platform === 'win32') {
100
+ // Windows Docker runners pull Linux images slowly/unreliably and the
101
+ // capture path is exercised by the failed-pull test above; skip to avoid
102
+ // network-dependent timeouts. The real-pull streaming path is Unix-only.
103
+ console.log = originalLog;
104
+ console.log(' Skipping: real pull is unreliable on Windows Docker');
105
+ return;
106
+ }
107
+
108
+ // Use a tiny image and force a real pull by removing it first.
109
+ const image = 'hello-world:latest';
110
+ try {
111
+ require('child_process').execSync(`docker rmi ${image}`, {
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ });
114
+ } catch {
115
+ // image not present locally — that's fine, the pull below still runs
116
+ }
117
+
118
+ const logPath = makeTempLog();
119
+ try {
120
+ const result = dockerPullImage(image, logPath);
121
+ if (!result.success) {
122
+ // No network in this environment — cannot exercise the success path.
123
+ console.log = originalLog;
124
+ console.log(' Skipping: docker pull failed (no registry access?)');
125
+ return;
126
+ }
127
+
128
+ const contents = fs.readFileSync(logPath, 'utf8');
129
+ assert.ok(
130
+ contents.includes(`Preparing image ${image}`),
131
+ 'log must contain the "Preparing image …" start marker'
132
+ );
133
+ assert.ok(
134
+ /Image ready \(\d+\.\d+s\)/.test(contents),
135
+ 'log must contain "Image ready (<duration>)" marker'
136
+ );
137
+ assert.ok(
138
+ contents.includes('Pulling from') ||
139
+ contents.includes('Status:') ||
140
+ contents.includes('Pull complete'),
141
+ 'log must capture the teed docker pull progress output'
142
+ );
143
+ assert.ok(
144
+ dockerImageExists(image),
145
+ 'image should exist locally after a successful pull'
146
+ );
147
+ } finally {
148
+ fs.rmSync(logPath, { force: true });
149
+ }
150
+ });
151
+
152
+ it('does not write prep markers when no logPath is given (backward compat)', () => {
153
+ if (!isDockerAvailable()) {
154
+ console.log = originalLog;
155
+ console.log(' Skipping: docker daemon not available');
156
+ return;
157
+ }
158
+
159
+ // Without a logPath, dockerPullImage must still return the {success, output}
160
+ // shape and must not throw. We only assert the contract here.
161
+ const result = dockerPullImage('invalid..badname');
162
+ assert.strictEqual(result.success, false);
163
+ assert.strictEqual(typeof result.output, 'string');
164
+ });
165
+ });
166
+
167
+ describe('runInDocker threads logPath into dockerPullImage (issue #138)', () => {
168
+ it('source passes options.logPath to dockerPullImage', () => {
169
+ const isolationSrc = fs.readFileSync(
170
+ path.join(__dirname, '../src/lib/isolation.js'),
171
+ 'utf8'
172
+ );
173
+ assert.ok(
174
+ /dockerPullImage\(options\.image,\s*options\.logPath\)/.test(
175
+ isolationSrc
176
+ ),
177
+ 'runInDocker must pass options.logPath to dockerPullImage'
178
+ );
179
+ });
180
+
181
+ it('dockerPullImage tees output and writes prep markers in source', () => {
182
+ const dockerUtilsSrc = fs.readFileSync(
183
+ path.join(__dirname, '../src/lib/docker-utils.js'),
184
+ 'utf8'
185
+ );
186
+ assert.ok(
187
+ dockerUtilsSrc.includes('Preparing image'),
188
+ 'docker-utils must write a "Preparing image …" marker'
189
+ );
190
+ assert.ok(
191
+ dockerUtilsSrc.includes('Image ready'),
192
+ 'docker-utils must write an "Image ready (<duration>)" marker'
193
+ );
194
+ assert.ok(
195
+ dockerUtilsSrc.includes('tee -a'),
196
+ 'docker-utils must tee docker pull output into the log file'
197
+ );
198
+ });
199
+ });