start-command 0.20.0 → 0.20.1

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,30 @@
1
1
  # start-command
2
2
 
3
+ ## 0.20.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7e11703: feat: Sync JS implementation with Rust for timeline naming and virtual commands
8
+ - Renamed "spine" terminology to "timeline" throughout the codebase
9
+ - `SPINE` constant → `TIMELINE_MARKER` (old name deprecated)
10
+ - `createSpineLine()` → `createTimelineLine()` (old name deprecated)
11
+ - `createEmptySpineLine()` → `createEmptyTimelineLine()` (old name deprecated)
12
+ - Added virtual command visualization for Docker image pulls
13
+ - When Docker isolation requires pulling an image, it's shown as `$ docker pull <image>`
14
+ - Pull output is streamed in real-time with result markers (✓/✗)
15
+ - Only displayed when image actually needs to be pulled (conditional display)
16
+ - New API additions:
17
+ - `createVirtualCommandBlock()` - for formatting virtual commands
18
+ - `createVirtualCommandResult()` - for result markers
19
+ - `createTimelineSeparator()` - for separator between virtual and user commands
20
+ - `dockerImageExists()` - check if image is available locally
21
+ - `dockerPullImage()` - pull with streaming output
22
+ - `createStartBlock({ deferCommand })` - defer command display for multi-step execution
23
+ - Renamed "isolation backend" to "isolation environment" in docs and error messages
24
+ - All deprecated items have backward-compatible aliases for smooth migration
25
+
26
+ Fixes #70
27
+
3
28
  ## 0.20.0
4
29
 
5
30
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
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
@@ -512,12 +512,15 @@ async function runWithIsolation(
512
512
  }
513
513
 
514
514
  // Print start block with session ID and isolation info
515
+ // For docker isolation, defer command printing to allow virtual commands (like docker pull) to be shown first
516
+ const deferCommand = environment === 'docker';
515
517
  console.log(
516
518
  createStartBlock({
517
519
  sessionId,
518
520
  timestamp: startTime,
519
521
  command: cmd,
520
522
  extraLines,
523
+ deferCommand,
521
524
  })
522
525
  );
523
526
  console.log('');
@@ -558,8 +561,8 @@ async function runWithIsolation(
558
561
  let result;
559
562
 
560
563
  if (environment) {
561
- // Run in isolation backend (screen, tmux, docker, ssh)
562
- // Note: Isolation backends currently use native spawn/execSync
564
+ // Run in isolation environment (screen, tmux, docker, ssh)
565
+ // Note: Isolation environments currently use native spawn/execSync
563
566
  // Future: Add command-stream support with raw() function for multiplexers
564
567
  result = await runIsolated(environment, cmd, {
565
568
  session: options.session,
@@ -571,7 +574,7 @@ async function runWithIsolation(
571
574
  autoRemoveDockerContainer: options.autoRemoveDockerContainer,
572
575
  });
573
576
  } else if (createdUser) {
574
- // Run directly as the created user (no isolation backend)
577
+ // Run directly as the created user (no isolation environment)
575
578
  result = await runAsIsolatedUser(cmd, createdUser);
576
579
  } else {
577
580
  // This shouldn't happen in isolation mode, but handle gracefully
@@ -30,7 +30,7 @@ const DEBUG =
30
30
  process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
31
31
 
32
32
  /**
33
- * Valid isolation backends
33
+ * Valid isolation environments
34
34
  */
35
35
  const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];
36
36
 
@@ -80,7 +80,7 @@ function generateUUID() {
80
80
  */
81
81
  function parseArgs(args) {
82
82
  const wrapperOptions = {
83
- isolated: null, // Isolation backend: screen, tmux, docker, ssh
83
+ isolated: null, // Isolation environment: screen, tmux, docker, ssh
84
84
  attached: false, // Run in attached mode
85
85
  detached: false, // Run in detached mode
86
86
  session: null, // Session name
@@ -375,11 +375,11 @@ function validateOptions(options) {
375
375
  );
376
376
  }
377
377
 
378
- // Validate isolation backend
378
+ // Validate isolation environment
379
379
  if (options.isolated !== null) {
380
380
  if (!VALID_BACKENDS.includes(options.isolated)) {
381
381
  throw new Error(
382
- `Invalid isolation backend: "${options.isolated}". Valid options are: ${VALID_BACKENDS.join(', ')}`
382
+ `Invalid isolation environment: "${options.isolated}". Valid options are: ${VALID_BACKENDS.join(', ')}`
383
383
  );
384
384
  }
385
385
 
@@ -2,10 +2,12 @@
2
2
  * Docker utilities for the start-command CLI
3
3
  *
4
4
  * Provides Docker-related helper functions like detecting the default
5
- * Docker image based on the host operating system.
5
+ * Docker image based on the host operating system, checking if images
6
+ * exist locally, and pulling images with virtual command visualization.
6
7
  */
7
8
 
8
9
  const fs = require('fs');
10
+ const { execSync, spawnSync } = require('child_process');
9
11
 
10
12
  /**
11
13
  * Get the default Docker image based on the host operating system
@@ -84,6 +86,122 @@ function getDefaultDockerImage() {
84
86
  return 'alpine:latest';
85
87
  }
86
88
 
89
+ /**
90
+ * Check if a Docker image exists locally
91
+ * @param {string} image - Docker image name
92
+ * @returns {boolean} True if image exists locally
93
+ */
94
+ function dockerImageExists(image) {
95
+ try {
96
+ execSync(`docker image inspect ${image}`, {
97
+ stdio: ['pipe', 'pipe', 'pipe'],
98
+ });
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Pull a Docker image with output streaming
107
+ * Displays the pull operation as a virtual command in the timeline
108
+ * @param {string} image - Docker image to pull
109
+ * @returns {{success: boolean, output: string}} Pull result
110
+ */
111
+ function dockerPullImage(image) {
112
+ const {
113
+ createVirtualCommandBlock,
114
+ createVirtualCommandResult,
115
+ createTimelineSeparator,
116
+ } = require('./output-blocks');
117
+
118
+ // Print the virtual command line
119
+ console.log(createVirtualCommandBlock(`docker pull ${image}`));
120
+ console.log();
121
+
122
+ let output = '';
123
+ let success = false;
124
+
125
+ 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
+ });
130
+
131
+ success = result.status === 0;
132
+
133
+ if (result.stdout) {
134
+ output = result.stdout.toString();
135
+ }
136
+ if (result.stderr) {
137
+ output += result.stderr.toString();
138
+ }
139
+ } catch (err) {
140
+ console.error(`Failed to run docker pull: ${err.message}`);
141
+ output = err.message;
142
+ success = false;
143
+ }
144
+
145
+ // Print result marker and separator
146
+ console.log();
147
+ console.log(createVirtualCommandResult(success));
148
+ console.log(createTimelineSeparator());
149
+
150
+ return { success, output };
151
+ }
152
+
153
+ /**
154
+ * Check if Docker is available (command exists and daemon is running)
155
+ * @returns {boolean} True if Docker is available
156
+ */
157
+ function isDockerAvailable() {
158
+ try {
159
+ execSync('docker info', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Check if the Docker daemon can run Linux container images
168
+ * On Windows with Docker Desktop in Windows containers mode,
169
+ * Linux images like alpine:latest cannot be pulled or run.
170
+ * @returns {boolean} True if Linux Docker images can be run
171
+ */
172
+ function canRunLinuxDockerImages() {
173
+ if (!isDockerAvailable()) {
174
+ return false;
175
+ }
176
+
177
+ try {
178
+ // On Windows, check if Docker is configured for Linux containers
179
+ if (process.platform === 'win32') {
180
+ try {
181
+ const osType = execSync('docker info --format "{{.OSType}}"', {
182
+ encoding: 'utf8',
183
+ stdio: ['pipe', 'pipe', 'pipe'],
184
+ timeout: 5000,
185
+ }).trim();
186
+
187
+ // Docker must be using Linux containers to run Linux images
188
+ return osType === 'linux';
189
+ } catch {
190
+ // If we can't determine the OS type, assume Linux images won't work on Windows
191
+ return false;
192
+ }
193
+ }
194
+
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
87
201
  module.exports = {
88
202
  getDefaultDockerImage,
203
+ dockerImageExists,
204
+ dockerPullImage,
205
+ isDockerAvailable,
206
+ canRunLinuxDockerImages,
89
207
  };
@@ -618,6 +618,9 @@ function runInSsh(command, options = {}) {
618
618
  }
619
619
  }
620
620
 
621
+ // Import docker image utilities from docker-utils
622
+ const { dockerImageExists, dockerPullImage } = require('./docker-utils');
623
+
621
624
  /**
622
625
  * Run command in Docker container
623
626
  * @param {string} command - Command to execute
@@ -644,6 +647,24 @@ function runInDocker(command, options = {}) {
644
647
 
645
648
  const containerName = options.session || generateSessionName('docker');
646
649
 
650
+ // Check if image exists locally; if not, pull it as a virtual command
651
+ if (!dockerImageExists(options.image)) {
652
+ const pullResult = dockerPullImage(options.image);
653
+ if (!pullResult.success) {
654
+ return Promise.resolve({
655
+ success: false,
656
+ containerName: null,
657
+ message: `Failed to pull Docker image: ${options.image}`,
658
+ exitCode: 1,
659
+ });
660
+ }
661
+ }
662
+
663
+ // Print the user command (this appears after any virtual commands like docker pull)
664
+ const { createCommandLine } = require('./output-blocks');
665
+ console.log(createCommandLine(command));
666
+ console.log();
667
+
647
668
  try {
648
669
  if (options.detached) {
649
670
  // Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
@@ -753,8 +774,8 @@ function runInDocker(command, options = {}) {
753
774
  }
754
775
 
755
776
  /**
756
- * Run command in the specified isolation backend
757
- * @param {string} backend - Isolation backend (screen, tmux, docker, ssh)
777
+ * Run command in the specified isolation environment
778
+ * @param {string} backend - Isolation environment (screen, tmux, docker, ssh)
758
779
  * @param {string} command - Command to execute
759
780
  * @param {object} options - Options
760
781
  * @returns {Promise<{success: boolean, message: string}>}
@@ -772,7 +793,7 @@ function runIsolated(backend, command, options = {}) {
772
793
  default:
773
794
  return Promise.resolve({
774
795
  success: false,
775
- message: `Unknown isolation backend: ${backend}`,
796
+ message: `Unknown isolation environment: ${backend}`,
776
797
  });
777
798
  }
778
799
  }
@@ -885,7 +906,7 @@ function resetScreenVersionCache() {
885
906
  }
886
907
 
887
908
  /**
888
- * Run command as an isolated user (without isolation backend)
909
+ * Run command as an isolated user (without isolation environment)
889
910
  * Uses sudo -u to switch users
890
911
  * @param {string} cmd - Command to execute
891
912
  * @param {string} username - User to run as
@@ -915,58 +936,11 @@ function runAsIsolatedUser(cmd, username) {
915
936
  });
916
937
  }
917
938
 
918
- /**
919
- * Check if the Docker daemon can run Linux container images
920
- * On Windows with Docker Desktop in Windows containers mode,
921
- * Linux images like alpine:latest cannot be pulled or run.
922
- * @returns {boolean} True if Linux Docker images can be run
923
- */
924
- function canRunLinuxDockerImages() {
925
- if (!isCommandAvailable('docker')) {
926
- return false;
927
- }
928
-
929
- try {
930
- // First check if Docker daemon is running
931
- execSync('docker info', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 });
932
-
933
- // On Windows, check if Docker is configured for Linux containers
934
- if (process.platform === 'win32') {
935
- try {
936
- const osType = execSync('docker info --format "{{.OSType}}"', {
937
- encoding: 'utf8',
938
- stdio: ['pipe', 'pipe', 'pipe'],
939
- timeout: 5000,
940
- }).trim();
941
-
942
- // Docker must be using Linux containers to run Linux images
943
- if (osType !== 'linux') {
944
- if (DEBUG) {
945
- console.log(
946
- `[DEBUG] Docker is running in ${osType} containers mode, cannot run Linux images`
947
- );
948
- }
949
- return false;
950
- }
951
- } catch {
952
- // If we can't determine the OS type, assume Linux images won't work on Windows
953
- if (DEBUG) {
954
- console.log(
955
- '[DEBUG] Could not determine Docker OS type, assuming Linux images unavailable'
956
- );
957
- }
958
- return false;
959
- }
960
- }
961
-
962
- return true;
963
- } catch {
964
- return false;
965
- }
966
- }
967
-
968
- // Re-export getDefaultDockerImage from docker-utils for backwards compatibility
969
- const { getDefaultDockerImage } = require('./docker-utils');
939
+ // Re-export docker utilities from docker-utils for backwards compatibility
940
+ const {
941
+ getDefaultDockerImage,
942
+ canRunLinuxDockerImages,
943
+ } = require('./docker-utils');
970
944
 
971
945
  module.exports = {
972
946
  isCommandAvailable,
@@ -989,5 +963,8 @@ module.exports = {
989
963
  supportsLogfileOption,
990
964
  resetScreenVersionCache,
991
965
  canRunLinuxDockerImages,
966
+ // Re-exported from docker-utils for backwards compatibility
992
967
  getDefaultDockerImage,
968
+ dockerImageExists,
969
+ dockerPullImage,
993
970
  };
@@ -1,41 +1,60 @@
1
1
  /**
2
2
  * Output formatting utilities for nicely rendered command blocks
3
3
  *
4
- * Provides "status spine" format: a width-independent, lossless output format
4
+ * Provides "timeline" format: a width-independent, lossless output format
5
5
  * that works in TTY, tmux, SSH, CI, and logs.
6
6
  *
7
7
  * Core concepts:
8
- * - `│` prefix → tool metadata
9
- * - `$` → executed command
8
+ * - `│` prefix → tool metadata (timeline marker)
9
+ * - `$` → executed command (virtual or user command)
10
10
  * - No prefix → program output (stdout/stderr)
11
11
  * - Result marker (`✓` / `✗`) appears after output
12
12
  */
13
13
 
14
- // Metadata spine character
15
- const SPINE = '│';
14
+ // Timeline marker character (formerly called "spine")
15
+ const TIMELINE_MARKER = '│';
16
+
17
+ // Alias for backward compatibility
18
+ const SPINE = TIMELINE_MARKER;
16
19
 
17
20
  // Result markers
18
21
  const SUCCESS_MARKER = '✓';
19
22
  const FAILURE_MARKER = '✗';
20
23
 
21
24
  /**
22
- * Create a metadata line with spine prefix
25
+ * Create a metadata line with timeline marker prefix
23
26
  * @param {string} label - Label (e.g., 'session', 'start', 'exit')
24
27
  * @param {string} value - Value for the label
25
- * @returns {string} Formatted line with spine prefix
28
+ * @returns {string} Formatted line with timeline marker prefix
26
29
  */
27
- function createSpineLine(label, value) {
30
+ function createTimelineLine(label, value) {
28
31
  // Pad label to 10 characters for alignment
29
32
  const paddedLabel = label.padEnd(10);
30
- return `${SPINE} ${paddedLabel}${value}`;
33
+ return `${TIMELINE_MARKER} ${paddedLabel}${value}`;
34
+ }
35
+
36
+ /**
37
+ * Alias for backward compatibility
38
+ * @deprecated Use createTimelineLine instead
39
+ */
40
+ function createSpineLine(label, value) {
41
+ return createTimelineLine(label, value);
42
+ }
43
+
44
+ /**
45
+ * Create an empty timeline line (just the timeline marker character)
46
+ * @returns {string} Empty timeline line
47
+ */
48
+ function createEmptyTimelineLine() {
49
+ return TIMELINE_MARKER;
31
50
  }
32
51
 
33
52
  /**
34
- * Create an empty spine line (just the spine character)
35
- * @returns {string} Empty spine line
53
+ * Alias for backward compatibility
54
+ * @deprecated Use createEmptyTimelineLine instead
36
55
  */
37
56
  function createEmptySpineLine() {
38
- return SPINE;
57
+ return createEmptyTimelineLine();
39
58
  }
40
59
 
41
60
  /**
@@ -56,6 +75,34 @@ function getResultMarker(exitCode) {
56
75
  return exitCode === 0 ? SUCCESS_MARKER : FAILURE_MARKER;
57
76
  }
58
77
 
78
+ /**
79
+ * Create a virtual command block for setup steps (like docker pull)
80
+ * Virtual commands are displayed separately in the timeline to show
81
+ * intermediate steps that the tool performs automatically
82
+ * @param {string} command - The virtual command being executed
83
+ * @returns {string} Formatted command line
84
+ */
85
+ function createVirtualCommandBlock(command) {
86
+ return createCommandLine(command);
87
+ }
88
+
89
+ /**
90
+ * Create a result marker line for a virtual command
91
+ * @param {boolean} success - Whether the virtual command succeeded
92
+ * @returns {string} Result marker (✓ or ✗)
93
+ */
94
+ function createVirtualCommandResult(success) {
95
+ return success ? SUCCESS_MARKER : FAILURE_MARKER;
96
+ }
97
+
98
+ /**
99
+ * Create a separator line between virtual commands and user commands
100
+ * @returns {string} Empty timeline line
101
+ */
102
+ function createTimelineSeparator() {
103
+ return createEmptyTimelineLine();
104
+ }
105
+
59
106
  /**
60
107
  * Parse isolation metadata from extraLines
61
108
  * Extracts key-value pairs from lines like "[Isolation] Environment: docker, Mode: attached"
@@ -118,88 +165,97 @@ function parseIsolationMetadata(extraLines) {
118
165
  }
119
166
 
120
167
  /**
121
- * Generate isolation metadata lines for spine format
168
+ * Generate isolation metadata lines for timeline format
122
169
  * @param {object} metadata - Parsed isolation metadata
123
170
  * @param {string} [containerOrScreenName] - Container or screen session name
124
- * @returns {string[]} Array of spine-formatted isolation lines
171
+ * @returns {string[]} Array of timeline-formatted isolation lines
125
172
  */
126
173
  function generateIsolationLines(metadata, containerOrScreenName = null) {
127
174
  const lines = [];
128
175
 
129
176
  if (metadata.isolation) {
130
- lines.push(createSpineLine('isolation', metadata.isolation));
177
+ lines.push(createTimelineLine('isolation', metadata.isolation));
131
178
  }
132
179
 
133
180
  if (metadata.mode) {
134
- lines.push(createSpineLine('mode', metadata.mode));
181
+ lines.push(createTimelineLine('mode', metadata.mode));
135
182
  }
136
183
 
137
184
  if (metadata.image) {
138
- lines.push(createSpineLine('image', metadata.image));
185
+ lines.push(createTimelineLine('image', metadata.image));
139
186
  }
140
187
 
141
188
  // Use provided container/screen name or fall back to metadata.session
142
189
  if (metadata.isolation === 'docker') {
143
190
  const containerName = containerOrScreenName || metadata.session;
144
191
  if (containerName) {
145
- lines.push(createSpineLine('container', containerName));
192
+ lines.push(createTimelineLine('container', containerName));
146
193
  }
147
194
  } else if (metadata.isolation === 'screen') {
148
195
  const screenName = containerOrScreenName || metadata.session;
149
196
  if (screenName) {
150
- lines.push(createSpineLine('screen', screenName));
197
+ lines.push(createTimelineLine('screen', screenName));
151
198
  }
152
199
  } else if (metadata.isolation === 'tmux') {
153
200
  const tmuxName = containerOrScreenName || metadata.session;
154
201
  if (tmuxName) {
155
- lines.push(createSpineLine('tmux', tmuxName));
202
+ lines.push(createTimelineLine('tmux', tmuxName));
156
203
  }
157
204
  } else if (metadata.isolation === 'ssh') {
158
205
  if (metadata.endpoint) {
159
- lines.push(createSpineLine('endpoint', metadata.endpoint));
206
+ lines.push(createTimelineLine('endpoint', metadata.endpoint));
160
207
  }
161
208
  }
162
209
 
163
210
  if (metadata.user) {
164
- lines.push(createSpineLine('user', metadata.user));
211
+ lines.push(createTimelineLine('user', metadata.user));
165
212
  }
166
213
 
167
214
  return lines;
168
215
  }
169
216
 
170
217
  /**
171
- * Create a start block for command execution using status spine format
218
+ * Create a start block for command execution using timeline format
172
219
  * @param {object} options - Options for the block
173
220
  * @param {string} options.sessionId - Session UUID
174
221
  * @param {string} options.timestamp - Timestamp string
175
222
  * @param {string} options.command - Command being executed
176
223
  * @param {string[]} [options.extraLines] - Additional lines with isolation info
224
+ * @param {boolean} [options.deferCommand] - If true, omit command line (for virtual command handling)
177
225
  * @param {string} [options.style] - Ignored (kept for backward compatibility)
178
226
  * @param {number} [options.width] - Ignored (kept for backward compatibility)
179
- * @returns {string} Formatted start block in spine format
227
+ * @returns {string} Formatted start block in timeline format
180
228
  */
181
229
  function createStartBlock(options) {
182
- const { sessionId, timestamp, command, extraLines = [] } = options;
230
+ const {
231
+ sessionId,
232
+ timestamp,
233
+ command,
234
+ extraLines = [],
235
+ deferCommand = false,
236
+ } = options;
183
237
 
184
238
  const lines = [];
185
239
 
186
240
  // Header: session and start time
187
- lines.push(createSpineLine('session', sessionId));
188
- lines.push(createSpineLine('start', timestamp));
241
+ lines.push(createTimelineLine('session', sessionId));
242
+ lines.push(createTimelineLine('start', timestamp));
189
243
 
190
244
  // Parse and add isolation metadata if present
191
245
  const metadata = parseIsolationMetadata(extraLines);
192
246
 
193
247
  if (metadata.isolation) {
194
- lines.push(createEmptySpineLine());
248
+ lines.push(createEmptyTimelineLine());
195
249
  lines.push(...generateIsolationLines(metadata));
196
250
  }
197
251
 
198
- // Empty spine line before command
199
- lines.push(createEmptySpineLine());
252
+ // Empty timeline line before command (always needed for separation)
253
+ lines.push(createEmptyTimelineLine());
200
254
 
201
- // Command line
202
- lines.push(createCommandLine(command));
255
+ // Command line (unless deferred for virtual command handling)
256
+ if (!deferCommand) {
257
+ lines.push(createCommandLine(command));
258
+ }
203
259
 
204
260
  return lines.join('\n');
205
261
  }
@@ -224,7 +280,7 @@ function formatDuration(durationMs) {
224
280
  }
225
281
 
226
282
  /**
227
- * Create a finish block for command execution using status spine format
283
+ * Create a finish block for command execution using timeline format
228
284
  *
229
285
  * Bottom block ordering rules:
230
286
  * 1. Result marker (✓ or ✗)
@@ -232,7 +288,7 @@ function formatDuration(durationMs) {
232
288
  * 3. duration
233
289
  * 4. exit code
234
290
  * 5. (repeated isolation metadata, if any)
235
- * 6. empty spine line
291
+ * 6. empty timeline line
236
292
  * 7. log path (always second-to-last)
237
293
  * 8. session ID (always last)
238
294
  *
@@ -246,7 +302,7 @@ function formatDuration(durationMs) {
246
302
  * @param {string[]} [options.extraLines] - Isolation info for repetition in footer
247
303
  * @param {string} [options.style] - Ignored (kept for backward compatibility)
248
304
  * @param {number} [options.width] - Ignored (kept for backward compatibility)
249
- * @returns {string} Formatted finish block in spine format
305
+ * @returns {string} Formatted finish block in timeline format
250
306
  */
251
307
  function createFinishBlock(options) {
252
308
  const {
@@ -264,27 +320,27 @@ function createFinishBlock(options) {
264
320
  lines.push(getResultMarker(exitCode));
265
321
 
266
322
  // Finish metadata
267
- lines.push(createSpineLine('finish', timestamp));
323
+ lines.push(createTimelineLine('finish', timestamp));
268
324
 
269
325
  if (durationMs !== undefined && durationMs !== null) {
270
- lines.push(createSpineLine('duration', formatDuration(durationMs)));
326
+ lines.push(createTimelineLine('duration', formatDuration(durationMs)));
271
327
  }
272
328
 
273
- lines.push(createSpineLine('exit', String(exitCode)));
329
+ lines.push(createTimelineLine('exit', String(exitCode)));
274
330
 
275
331
  // Repeat isolation metadata if present
276
332
  const metadata = parseIsolationMetadata(extraLines);
277
333
  if (metadata.isolation) {
278
- lines.push(createEmptySpineLine());
334
+ lines.push(createEmptyTimelineLine());
279
335
  lines.push(...generateIsolationLines(metadata));
280
336
  }
281
337
 
282
- // Empty spine line before final two entries
283
- lines.push(createEmptySpineLine());
338
+ // Empty timeline line before final two entries
339
+ lines.push(createEmptyTimelineLine());
284
340
 
285
341
  // Log and session are ALWAYS last (in that order)
286
- lines.push(createSpineLine('log', logPath));
287
- lines.push(createSpineLine('session', sessionId));
342
+ lines.push(createTimelineLine('log', logPath));
343
+ lines.push(createTimelineLine('session', sessionId));
288
344
 
289
345
  return lines.join('\n');
290
346
  }
@@ -399,17 +455,25 @@ function formatAsNestedLinksNotation(obj, indent = 2, depth = 0) {
399
455
  }
400
456
 
401
457
  module.exports = {
402
- // Status spine format API
403
- SPINE,
458
+ // Timeline format API (formerly "status spine")
459
+ TIMELINE_MARKER,
460
+ SPINE, // deprecated, use TIMELINE_MARKER
404
461
  SUCCESS_MARKER,
405
462
  FAILURE_MARKER,
406
- createSpineLine,
407
- createEmptySpineLine,
463
+ createTimelineLine,
464
+ createSpineLine, // deprecated, use createTimelineLine
465
+ createEmptyTimelineLine,
466
+ createEmptySpineLine, // deprecated, use createEmptyTimelineLine
408
467
  createCommandLine,
409
468
  getResultMarker,
410
469
  parseIsolationMetadata,
411
470
  generateIsolationLines,
412
471
 
472
+ // Virtual command API (for multi-step execution)
473
+ createVirtualCommandBlock,
474
+ createVirtualCommandResult,
475
+ createTimelineSeparator,
476
+
413
477
  // Main block creation functions
414
478
  createStartBlock,
415
479
  createFinishBlock,
@@ -463,7 +463,7 @@ describe('parseArgs', () => {
463
463
  it('should throw error for invalid backend', () => {
464
464
  assert.throws(() => {
465
465
  parseArgs(['--isolated', 'invalid-backend', '--', 'npm', 'test']);
466
- }, /Invalid isolation backend/);
466
+ }, /Invalid isolation environment/);
467
467
  });
468
468
 
469
469
  it('should list valid backends in error message', () => {
@@ -89,8 +89,8 @@ describe('Isolation Module', () => {
89
89
  });
90
90
  });
91
91
 
92
- describe('isolation backend checks', () => {
93
- // These tests check if specific backends are available
92
+ describe('isolation environment checks', () => {
93
+ // These tests check if specific isolation environments are available
94
94
  // They don't fail if not installed, just report status
95
95
 
96
96
  it('should check if screen is available', () => {
@@ -710,7 +710,7 @@ describe('Isolation Runner with Available Backends', () => {
710
710
  // Test with a backend that returns predictable error for missing tools
711
711
  const result = await runIsolated('nonexistent-backend', 'echo test', {});
712
712
  assert.strictEqual(result.success, false);
713
- assert.ok(result.message.includes('Unknown isolation backend'));
713
+ assert.ok(result.message.includes('Unknown isolation environment'));
714
714
  });
715
715
 
716
716
  it('should pass options to backend', async () => {
@@ -1,23 +1,36 @@
1
1
  /**
2
2
  * Tests for output-blocks module
3
3
  *
4
- * Tests the "status spine" format: width-independent, lossless output
4
+ * Tests the "timeline" format: width-independent, lossless output
5
+ * (formerly called "status spine" format)
5
6
  */
6
7
 
7
8
  const { describe, it, expect } = require('bun:test');
8
9
 
9
10
  const {
10
- // Spine format exports
11
+ // Timeline format exports (new names)
12
+ TIMELINE_MARKER,
13
+ createTimelineLine,
14
+ createEmptyTimelineLine,
15
+
16
+ // Deprecated aliases (for backward compatibility)
11
17
  SPINE,
12
- SUCCESS_MARKER,
13
- FAILURE_MARKER,
14
18
  createSpineLine,
15
19
  createEmptySpineLine,
20
+
21
+ // Common exports
22
+ SUCCESS_MARKER,
23
+ FAILURE_MARKER,
16
24
  createCommandLine,
17
25
  getResultMarker,
18
26
  parseIsolationMetadata,
19
27
  generateIsolationLines,
20
28
 
29
+ // Virtual command API
30
+ createVirtualCommandBlock,
31
+ createVirtualCommandResult,
32
+ createTimelineSeparator,
33
+
21
34
  // Main block functions
22
35
  createStartBlock,
23
36
  createFinishBlock,
@@ -29,9 +42,13 @@ const {
29
42
  } = require('../src/lib/output-blocks');
30
43
 
31
44
  describe('output-blocks module', () => {
32
- describe('spine format constants', () => {
33
- it('should export spine character', () => {
34
- expect(SPINE).toBe('│');
45
+ describe('timeline format constants', () => {
46
+ it('should export timeline marker character', () => {
47
+ expect(TIMELINE_MARKER).toBe('│');
48
+ });
49
+
50
+ it('should export spine as alias for backward compatibility', () => {
51
+ expect(SPINE).toBe(TIMELINE_MARKER);
35
52
  });
36
53
 
37
54
  it('should export result markers', () => {
@@ -40,24 +57,37 @@ describe('output-blocks module', () => {
40
57
  });
41
58
  });
42
59
 
43
- describe('createSpineLine', () => {
44
- it('should create a line with spine prefix and padded label', () => {
45
- const line = createSpineLine('session', 'abc-123');
60
+ describe('createTimelineLine', () => {
61
+ it('should create a line with timeline marker prefix and padded label', () => {
62
+ const line = createTimelineLine('session', 'abc-123');
46
63
  expect(line).toBe('│ session abc-123');
47
64
  });
48
65
 
49
66
  it('should pad labels to 10 characters', () => {
50
- const shortLabel = createSpineLine('exit', '0');
67
+ const shortLabel = createTimelineLine('exit', '0');
51
68
  expect(shortLabel).toBe('│ exit 0');
52
69
 
53
- const longLabel = createSpineLine('isolation', 'docker');
70
+ const longLabel = createTimelineLine('isolation', 'docker');
54
71
  expect(longLabel).toBe('│ isolation docker');
55
72
  });
56
73
  });
57
74
 
58
- describe('createEmptySpineLine', () => {
59
- it('should create just the spine character', () => {
60
- expect(createEmptySpineLine()).toBe('');
75
+ describe('createSpineLine (deprecated alias)', () => {
76
+ it('should work as alias for createTimelineLine', () => {
77
+ const line = createSpineLine('session', 'abc-123');
78
+ expect(line).toBe(createTimelineLine('session', 'abc-123'));
79
+ });
80
+ });
81
+
82
+ describe('createEmptyTimelineLine', () => {
83
+ it('should create just the timeline marker character', () => {
84
+ expect(createEmptyTimelineLine()).toBe('│');
85
+ });
86
+ });
87
+
88
+ describe('createEmptySpineLine (deprecated alias)', () => {
89
+ it('should work as alias for createEmptyTimelineLine', () => {
90
+ expect(createEmptySpineLine()).toBe(createEmptyTimelineLine());
61
91
  });
62
92
  });
63
93
 
@@ -373,6 +403,62 @@ describe('output-blocks module', () => {
373
403
  expect(formatAsNestedLinksNotation(null)).toBe('null');
374
404
  });
375
405
  });
406
+
407
+ describe('Virtual Command API', () => {
408
+ describe('createVirtualCommandBlock', () => {
409
+ it('should create a command line for virtual commands', () => {
410
+ const block = createVirtualCommandBlock('docker pull ubuntu:latest');
411
+ expect(block).toBe('$ docker pull ubuntu:latest');
412
+ });
413
+
414
+ it('should behave like createCommandLine', () => {
415
+ const virtualBlock = createVirtualCommandBlock('npm install');
416
+ const commandLine = createCommandLine('npm install');
417
+ expect(virtualBlock).toBe(commandLine);
418
+ });
419
+ });
420
+
421
+ describe('createVirtualCommandResult', () => {
422
+ it('should return success marker for true', () => {
423
+ expect(createVirtualCommandResult(true)).toBe('✓');
424
+ });
425
+
426
+ it('should return failure marker for false', () => {
427
+ expect(createVirtualCommandResult(false)).toBe('✗');
428
+ });
429
+ });
430
+
431
+ describe('createTimelineSeparator', () => {
432
+ it('should create an empty timeline line', () => {
433
+ expect(createTimelineSeparator()).toBe('│');
434
+ });
435
+
436
+ it('should behave like createEmptyTimelineLine', () => {
437
+ expect(createTimelineSeparator()).toBe(createEmptyTimelineLine());
438
+ });
439
+ });
440
+ });
441
+
442
+ describe('createStartBlock with deferCommand', () => {
443
+ it('should include command line by default', () => {
444
+ const block = createStartBlock({
445
+ sessionId: 'test-uuid',
446
+ timestamp: '2025-01-01 00:00:00',
447
+ command: 'echo hello',
448
+ });
449
+ expect(block).toContain('$ echo hello');
450
+ });
451
+
452
+ it('should omit command line when deferCommand is true', () => {
453
+ const block = createStartBlock({
454
+ sessionId: 'test-uuid',
455
+ timestamp: '2025-01-01 00:00:00',
456
+ command: 'echo hello',
457
+ deferCommand: true,
458
+ });
459
+ expect(block).not.toContain('$ echo hello');
460
+ });
461
+ });
376
462
  });
377
463
 
378
464
  console.log('=== Output Blocks Unit Tests ===');