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 +25 -0
- package/package.json +1 -1
- package/src/bin/cli.js +6 -3
- package/src/lib/args-parser.js +4 -4
- package/src/lib/docker-utils.js +119 -1
- package/src/lib/isolation.js +33 -56
- package/src/lib/output-blocks.js +111 -47
- package/test/args-parser.test.js +1 -1
- package/test/isolation.test.js +3 -3
- package/test/output-blocks.test.js +101 -15
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
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
|
|
562
|
-
// Note: Isolation
|
|
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
|
|
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
|
package/src/lib/args-parser.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
382
|
+
`Invalid isolation environment: "${options.isolated}". Valid options are: ${VALID_BACKENDS.join(', ')}`
|
|
383
383
|
);
|
|
384
384
|
}
|
|
385
385
|
|
package/src/lib/docker-utils.js
CHANGED
|
@@ -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
|
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -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
|
|
757
|
-
* @param {string} backend - Isolation
|
|
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
|
|
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
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
};
|
package/src/lib/output-blocks.js
CHANGED
|
@@ -1,41 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Output formatting utilities for nicely rendered command blocks
|
|
3
3
|
*
|
|
4
|
-
* Provides "
|
|
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
|
-
//
|
|
15
|
-
const
|
|
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
|
|
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
|
|
28
|
+
* @returns {string} Formatted line with timeline marker prefix
|
|
26
29
|
*/
|
|
27
|
-
function
|
|
30
|
+
function createTimelineLine(label, value) {
|
|
28
31
|
// Pad label to 10 characters for alignment
|
|
29
32
|
const paddedLabel = label.padEnd(10);
|
|
30
|
-
return `${
|
|
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
|
-
*
|
|
35
|
-
* @
|
|
53
|
+
* Alias for backward compatibility
|
|
54
|
+
* @deprecated Use createEmptyTimelineLine instead
|
|
36
55
|
*/
|
|
37
56
|
function createEmptySpineLine() {
|
|
38
|
-
return
|
|
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
|
|
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
|
|
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(
|
|
177
|
+
lines.push(createTimelineLine('isolation', metadata.isolation));
|
|
131
178
|
}
|
|
132
179
|
|
|
133
180
|
if (metadata.mode) {
|
|
134
|
-
lines.push(
|
|
181
|
+
lines.push(createTimelineLine('mode', metadata.mode));
|
|
135
182
|
}
|
|
136
183
|
|
|
137
184
|
if (metadata.image) {
|
|
138
|
-
lines.push(
|
|
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(
|
|
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(
|
|
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(
|
|
202
|
+
lines.push(createTimelineLine('tmux', tmuxName));
|
|
156
203
|
}
|
|
157
204
|
} else if (metadata.isolation === 'ssh') {
|
|
158
205
|
if (metadata.endpoint) {
|
|
159
|
-
lines.push(
|
|
206
|
+
lines.push(createTimelineLine('endpoint', metadata.endpoint));
|
|
160
207
|
}
|
|
161
208
|
}
|
|
162
209
|
|
|
163
210
|
if (metadata.user) {
|
|
164
|
-
lines.push(
|
|
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
|
|
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
|
|
227
|
+
* @returns {string} Formatted start block in timeline format
|
|
180
228
|
*/
|
|
181
229
|
function createStartBlock(options) {
|
|
182
|
-
const {
|
|
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(
|
|
188
|
-
lines.push(
|
|
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(
|
|
248
|
+
lines.push(createEmptyTimelineLine());
|
|
195
249
|
lines.push(...generateIsolationLines(metadata));
|
|
196
250
|
}
|
|
197
251
|
|
|
198
|
-
// Empty
|
|
199
|
-
lines.push(
|
|
252
|
+
// Empty timeline line before command (always needed for separation)
|
|
253
|
+
lines.push(createEmptyTimelineLine());
|
|
200
254
|
|
|
201
|
-
// Command line
|
|
202
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
323
|
+
lines.push(createTimelineLine('finish', timestamp));
|
|
268
324
|
|
|
269
325
|
if (durationMs !== undefined && durationMs !== null) {
|
|
270
|
-
lines.push(
|
|
326
|
+
lines.push(createTimelineLine('duration', formatDuration(durationMs)));
|
|
271
327
|
}
|
|
272
328
|
|
|
273
|
-
lines.push(
|
|
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(
|
|
334
|
+
lines.push(createEmptyTimelineLine());
|
|
279
335
|
lines.push(...generateIsolationLines(metadata));
|
|
280
336
|
}
|
|
281
337
|
|
|
282
|
-
// Empty
|
|
283
|
-
lines.push(
|
|
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(
|
|
287
|
-
lines.push(
|
|
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
|
-
//
|
|
403
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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,
|
package/test/args-parser.test.js
CHANGED
|
@@ -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
|
|
466
|
+
}, /Invalid isolation environment/);
|
|
467
467
|
});
|
|
468
468
|
|
|
469
469
|
it('should list valid backends in error message', () => {
|
package/test/isolation.test.js
CHANGED
|
@@ -89,8 +89,8 @@ describe('Isolation Module', () => {
|
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
describe('isolation
|
|
93
|
-
// These tests check if specific
|
|
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
|
|
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 "
|
|
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
|
-
//
|
|
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('
|
|
33
|
-
it('should export
|
|
34
|
-
expect(
|
|
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('
|
|
44
|
-
it('should create a line with
|
|
45
|
-
const line =
|
|
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 =
|
|
67
|
+
const shortLabel = createTimelineLine('exit', '0');
|
|
51
68
|
expect(shortLabel).toBe('│ exit 0');
|
|
52
69
|
|
|
53
|
-
const longLabel =
|
|
70
|
+
const longLabel = createTimelineLine('isolation', 'docker');
|
|
54
71
|
expect(longLabel).toBe('│ isolation docker');
|
|
55
72
|
});
|
|
56
73
|
});
|
|
57
74
|
|
|
58
|
-
describe('
|
|
59
|
-
it('should
|
|
60
|
-
|
|
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 ===');
|