start-command 0.20.3 → 0.21.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 +63 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +220 -45
- package/src/lib/command-builder.js +130 -0
- package/src/lib/docker-utils.js +3 -1
- package/src/lib/isolation.js +31 -4
- package/src/lib/sequence-parser.js +231 -0
- package/test/args-parser.test.js +6 -6
- package/test/isolation-stacking.test.js +366 -0
- package/test/output-blocks.test.js +28 -0
- package/test/sequence-parser.test.js +237 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequence Parser for Isolation Stacking
|
|
3
|
+
*
|
|
4
|
+
* Parses space-separated sequences with underscore placeholders for
|
|
5
|
+
* distributing options across isolation levels.
|
|
6
|
+
*
|
|
7
|
+
* Based on Links Notation conventions (https://github.com/link-foundation/links-notation)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a space-separated sequence with underscore placeholders
|
|
12
|
+
* @param {string} value - Space-separated values (e.g., "screen ssh docker")
|
|
13
|
+
* @returns {(string|null)[]} Array of values, with null for underscore placeholders
|
|
14
|
+
*/
|
|
15
|
+
function parseSequence(value) {
|
|
16
|
+
if (!value || typeof value !== 'string') {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (!trimmed) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Split by whitespace
|
|
26
|
+
const parts = trimmed.split(/\s+/);
|
|
27
|
+
|
|
28
|
+
// Convert underscores to null (placeholder)
|
|
29
|
+
return parts.map((v) => (v === '_' ? null : v));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format a sequence array back to a string
|
|
34
|
+
* @param {(string|null)[]} sequence - Array of values with nulls for placeholders
|
|
35
|
+
* @returns {string} Space-separated string with underscores for nulls
|
|
36
|
+
*/
|
|
37
|
+
function formatSequence(sequence) {
|
|
38
|
+
if (!Array.isArray(sequence) || sequence.length === 0) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return sequence.map((v) => (v === null ? '_' : v)).join(' ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Shift sequence by removing first element
|
|
47
|
+
* @param {(string|null)[]} sequence - Parsed sequence
|
|
48
|
+
* @returns {(string|null)[]} New sequence without first element
|
|
49
|
+
*/
|
|
50
|
+
function shiftSequence(sequence) {
|
|
51
|
+
if (!Array.isArray(sequence) || sequence.length === 0) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return sequence.slice(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a string represents a multi-value sequence (contains spaces)
|
|
59
|
+
* @param {string} value - Value to check
|
|
60
|
+
* @returns {boolean} True if contains spaces (multi-value)
|
|
61
|
+
*/
|
|
62
|
+
function isSequence(value) {
|
|
63
|
+
return typeof value === 'string' && value.includes(' ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Distribute a single option value across all isolation levels
|
|
68
|
+
* If the value is a sequence, validate length matches stack depth
|
|
69
|
+
* If the value is a single value, replicate it for all levels
|
|
70
|
+
*
|
|
71
|
+
* @param {string} optionValue - Space-separated or single value
|
|
72
|
+
* @param {number} stackDepth - Number of isolation levels
|
|
73
|
+
* @param {string} optionName - Name of option for error messages
|
|
74
|
+
* @returns {(string|null)[]} Array of values for each level
|
|
75
|
+
* @throws {Error} If sequence length doesn't match stack depth
|
|
76
|
+
*/
|
|
77
|
+
function distributeOption(optionValue, stackDepth, optionName) {
|
|
78
|
+
if (!optionValue) {
|
|
79
|
+
return Array(stackDepth).fill(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parsed = parseSequence(optionValue);
|
|
83
|
+
|
|
84
|
+
// Single value: replicate for all levels
|
|
85
|
+
if (parsed.length === 1 && stackDepth > 1) {
|
|
86
|
+
return Array(stackDepth).fill(parsed[0]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sequence: validate length matches
|
|
90
|
+
if (parsed.length !== stackDepth) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${optionName} has ${parsed.length} value(s) but isolation stack has ${stackDepth} level(s). ` +
|
|
93
|
+
`Use underscores (_) as placeholders for levels that don't need this option.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the value at a specific level from a distributed option
|
|
102
|
+
* @param {(string|null)[]} distributedOption - Distributed option array
|
|
103
|
+
* @param {number} level - Zero-based level index
|
|
104
|
+
* @returns {string|null} Value at that level or null
|
|
105
|
+
*/
|
|
106
|
+
function getValueAtLevel(distributedOption, level) {
|
|
107
|
+
if (
|
|
108
|
+
!Array.isArray(distributedOption) ||
|
|
109
|
+
level < 0 ||
|
|
110
|
+
level >= distributedOption.length
|
|
111
|
+
) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return distributedOption[level];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate that required options are provided for specific isolation types
|
|
119
|
+
* @param {(string|null)[]} isolationStack - Stack of isolation backends
|
|
120
|
+
* @param {object} options - Object containing distributed options
|
|
121
|
+
* @param {(string|null)[]} options.endpoints - Distributed endpoints for SSH
|
|
122
|
+
* @param {(string|null)[]} options.images - Distributed images for Docker
|
|
123
|
+
* @throws {Error} If required options are missing
|
|
124
|
+
*/
|
|
125
|
+
function validateStackOptions(isolationStack, options) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
|
|
128
|
+
isolationStack.forEach((backend, i) => {
|
|
129
|
+
if (backend === 'ssh') {
|
|
130
|
+
if (!options.endpoints || !options.endpoints[i]) {
|
|
131
|
+
errors.push(
|
|
132
|
+
`Level ${i + 1} is SSH but no endpoint specified. ` +
|
|
133
|
+
`Use --endpoint with a value at position ${i + 1}.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Docker doesn't require image - has default
|
|
138
|
+
// Screen and tmux don't require special options
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (errors.length > 0) {
|
|
142
|
+
throw new Error(errors.join('\n'));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build remaining options for next isolation level
|
|
148
|
+
* @param {object} options - Current level options
|
|
149
|
+
* @returns {object} Options for next level
|
|
150
|
+
*/
|
|
151
|
+
function buildNextLevelOptions(options) {
|
|
152
|
+
const next = { ...options };
|
|
153
|
+
|
|
154
|
+
// Shift isolation stack
|
|
155
|
+
if (next.isolatedStack && next.isolatedStack.length > 1) {
|
|
156
|
+
next.isolatedStack = shiftSequence(next.isolatedStack);
|
|
157
|
+
next.isolated = next.isolatedStack[0];
|
|
158
|
+
} else {
|
|
159
|
+
next.isolatedStack = [];
|
|
160
|
+
next.isolated = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Shift distributed options
|
|
164
|
+
if (next.imageStack && next.imageStack.length > 1) {
|
|
165
|
+
next.imageStack = shiftSequence(next.imageStack);
|
|
166
|
+
next.image = next.imageStack[0];
|
|
167
|
+
} else if (next.imageStack) {
|
|
168
|
+
next.imageStack = [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (next.endpointStack && next.endpointStack.length > 1) {
|
|
172
|
+
next.endpointStack = shiftSequence(next.endpointStack);
|
|
173
|
+
next.endpoint = next.endpointStack[0];
|
|
174
|
+
} else if (next.endpointStack) {
|
|
175
|
+
next.endpointStack = [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (next.sessionStack && next.sessionStack.length > 1) {
|
|
179
|
+
next.sessionStack = shiftSequence(next.sessionStack);
|
|
180
|
+
next.session = next.sessionStack[0];
|
|
181
|
+
} else if (next.sessionStack) {
|
|
182
|
+
next.sessionStack = [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Format isolation chain for display
|
|
190
|
+
* @param {(string|null)[]} stack - Isolation stack
|
|
191
|
+
* @param {object} options - Options with distributed values
|
|
192
|
+
* @returns {string} Formatted chain (e.g., "screen → ssh@host → docker:ubuntu")
|
|
193
|
+
*/
|
|
194
|
+
function formatIsolationChain(stack, options = {}) {
|
|
195
|
+
if (!Array.isArray(stack) || stack.length === 0) {
|
|
196
|
+
return '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return stack
|
|
200
|
+
.map((backend, i) => {
|
|
201
|
+
if (!backend) {
|
|
202
|
+
return '_';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (backend === 'ssh' && options.endpointStack?.[i]) {
|
|
206
|
+
return `ssh@${options.endpointStack[i]}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (backend === 'docker' && options.imageStack?.[i]) {
|
|
210
|
+
// Extract short image name
|
|
211
|
+
const image = options.imageStack[i];
|
|
212
|
+
const shortName = image.split(':')[0].split('/').pop();
|
|
213
|
+
return `docker:${shortName}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return backend;
|
|
217
|
+
})
|
|
218
|
+
.join(' → ');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
parseSequence,
|
|
223
|
+
formatSequence,
|
|
224
|
+
shiftSequence,
|
|
225
|
+
isSequence,
|
|
226
|
+
distributeOption,
|
|
227
|
+
getValueAtLevel,
|
|
228
|
+
validateStackOptions,
|
|
229
|
+
buildNextLevelOptions,
|
|
230
|
+
formatIsolationChain,
|
|
231
|
+
};
|
package/test/args-parser.test.js
CHANGED
|
@@ -232,7 +232,7 @@ describe('parseArgs', () => {
|
|
|
232
232
|
'npm',
|
|
233
233
|
'test',
|
|
234
234
|
]);
|
|
235
|
-
}, /--image option is only valid
|
|
235
|
+
}, /--image option is only valid when isolation stack includes docker/);
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
238
|
|
|
@@ -264,7 +264,7 @@ describe('parseArgs', () => {
|
|
|
264
264
|
it('should throw error for ssh without endpoint', () => {
|
|
265
265
|
assert.throws(() => {
|
|
266
266
|
parseArgs(['--isolated', 'ssh', '--', 'npm', 'test']);
|
|
267
|
-
}, /SSH isolation requires --endpoint option/);
|
|
267
|
+
}, /SSH isolation at level 1 requires --endpoint option/);
|
|
268
268
|
});
|
|
269
269
|
|
|
270
270
|
it('should throw error for endpoint with non-ssh backend', () => {
|
|
@@ -278,7 +278,7 @@ describe('parseArgs', () => {
|
|
|
278
278
|
'npm',
|
|
279
279
|
'test',
|
|
280
280
|
]);
|
|
281
|
-
}, /--endpoint option is only valid
|
|
281
|
+
}, /--endpoint option is only valid when isolation stack includes ssh/);
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
284
|
|
|
@@ -384,13 +384,13 @@ describe('parseArgs', () => {
|
|
|
384
384
|
'npm',
|
|
385
385
|
'test',
|
|
386
386
|
]);
|
|
387
|
-
}, /--auto-remove-docker-container option is only valid
|
|
387
|
+
}, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
|
|
388
388
|
});
|
|
389
389
|
|
|
390
390
|
it('should throw error for auto-remove-docker-container without isolation', () => {
|
|
391
391
|
assert.throws(() => {
|
|
392
392
|
parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
|
|
393
|
-
}, /--auto-remove-docker-container option is only valid
|
|
393
|
+
}, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
|
|
394
394
|
});
|
|
395
395
|
|
|
396
396
|
it('should work with keep-alive and auto-remove-docker-container', () => {
|
|
@@ -706,7 +706,7 @@ describe('user isolation option', () => {
|
|
|
706
706
|
'npm',
|
|
707
707
|
'install',
|
|
708
708
|
]);
|
|
709
|
-
}, /--isolated-user is not supported with Docker isolation/);
|
|
709
|
+
}, /--isolated-user is not supported with Docker as the first isolation level/);
|
|
710
710
|
});
|
|
711
711
|
|
|
712
712
|
it('should work with tmux isolation', () => {
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for isolation stacking feature (issue #77)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { describe, it, expect } = require('bun:test');
|
|
6
|
+
const {
|
|
7
|
+
parseArgs,
|
|
8
|
+
validateOptions,
|
|
9
|
+
hasStackedIsolation,
|
|
10
|
+
MAX_ISOLATION_DEPTH,
|
|
11
|
+
} = require('../src/lib/args-parser');
|
|
12
|
+
|
|
13
|
+
describe('Isolation Stacking - Args Parser', () => {
|
|
14
|
+
describe('parseArgs with stacked --isolated', () => {
|
|
15
|
+
it('should parse single isolation (backward compatible)', () => {
|
|
16
|
+
const result = parseArgs(['--isolated', 'docker', '--', 'npm', 'test']);
|
|
17
|
+
expect(result.wrapperOptions.isolated).toBe('docker');
|
|
18
|
+
expect(result.wrapperOptions.isolatedStack).toEqual(['docker']);
|
|
19
|
+
expect(result.command).toBe('npm test');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should parse multi-level isolation', () => {
|
|
23
|
+
const result = parseArgs([
|
|
24
|
+
'--isolated',
|
|
25
|
+
'screen ssh docker',
|
|
26
|
+
'--endpoint',
|
|
27
|
+
'_ user@host _',
|
|
28
|
+
'--',
|
|
29
|
+
'npm',
|
|
30
|
+
'test',
|
|
31
|
+
]);
|
|
32
|
+
expect(result.wrapperOptions.isolated).toBe('screen');
|
|
33
|
+
expect(result.wrapperOptions.isolatedStack).toEqual([
|
|
34
|
+
'screen',
|
|
35
|
+
'ssh',
|
|
36
|
+
'docker',
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should parse 5-level isolation', () => {
|
|
41
|
+
const result = parseArgs([
|
|
42
|
+
'--isolated',
|
|
43
|
+
'screen ssh tmux ssh docker',
|
|
44
|
+
'--endpoint',
|
|
45
|
+
'_ user@server1 _ user@server2 _',
|
|
46
|
+
'--',
|
|
47
|
+
'npm',
|
|
48
|
+
'test',
|
|
49
|
+
]);
|
|
50
|
+
expect(result.wrapperOptions.isolatedStack).toEqual([
|
|
51
|
+
'screen',
|
|
52
|
+
'ssh',
|
|
53
|
+
'tmux',
|
|
54
|
+
'ssh',
|
|
55
|
+
'docker',
|
|
56
|
+
]);
|
|
57
|
+
expect(result.wrapperOptions.endpointStack).toEqual([
|
|
58
|
+
null,
|
|
59
|
+
'user@server1',
|
|
60
|
+
null,
|
|
61
|
+
'user@server2',
|
|
62
|
+
null,
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should parse --isolated=value syntax', () => {
|
|
67
|
+
const result = parseArgs(['--isolated=screen tmux', '--', 'ls']);
|
|
68
|
+
expect(result.wrapperOptions.isolatedStack).toEqual(['screen', 'tmux']);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('parseArgs with stacked --image', () => {
|
|
73
|
+
it('should parse single image (backward compatible)', () => {
|
|
74
|
+
const result = parseArgs([
|
|
75
|
+
'--isolated',
|
|
76
|
+
'docker',
|
|
77
|
+
'--image',
|
|
78
|
+
'ubuntu:22.04',
|
|
79
|
+
'--',
|
|
80
|
+
'bash',
|
|
81
|
+
]);
|
|
82
|
+
expect(result.wrapperOptions.image).toBe('ubuntu:22.04');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should parse image sequence with placeholders', () => {
|
|
86
|
+
const result = parseArgs([
|
|
87
|
+
'--isolated',
|
|
88
|
+
'screen docker',
|
|
89
|
+
'--image',
|
|
90
|
+
'_ ubuntu:22.04',
|
|
91
|
+
'--',
|
|
92
|
+
'bash',
|
|
93
|
+
]);
|
|
94
|
+
expect(result.wrapperOptions.imageStack).toEqual([null, 'ubuntu:22.04']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should parse --image=value syntax', () => {
|
|
98
|
+
const result = parseArgs([
|
|
99
|
+
'--isolated',
|
|
100
|
+
'docker',
|
|
101
|
+
'--image=alpine:latest',
|
|
102
|
+
'--',
|
|
103
|
+
'sh',
|
|
104
|
+
]);
|
|
105
|
+
expect(result.wrapperOptions.image).toBe('alpine:latest');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('parseArgs with stacked --endpoint', () => {
|
|
110
|
+
it('should parse single endpoint (backward compatible)', () => {
|
|
111
|
+
const result = parseArgs([
|
|
112
|
+
'--isolated',
|
|
113
|
+
'ssh',
|
|
114
|
+
'--endpoint',
|
|
115
|
+
'user@host',
|
|
116
|
+
'--',
|
|
117
|
+
'ls',
|
|
118
|
+
]);
|
|
119
|
+
expect(result.wrapperOptions.endpoint).toBe('user@host');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should parse endpoint sequence with placeholders', () => {
|
|
123
|
+
const result = parseArgs([
|
|
124
|
+
'--isolated',
|
|
125
|
+
'screen ssh ssh docker',
|
|
126
|
+
'--endpoint',
|
|
127
|
+
'_ user@host1 user@host2 _',
|
|
128
|
+
'--',
|
|
129
|
+
'bash',
|
|
130
|
+
]);
|
|
131
|
+
expect(result.wrapperOptions.endpointStack).toEqual([
|
|
132
|
+
null,
|
|
133
|
+
'user@host1',
|
|
134
|
+
'user@host2',
|
|
135
|
+
null,
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('hasStackedIsolation', () => {
|
|
141
|
+
it('should return true for multi-level', () => {
|
|
142
|
+
const { wrapperOptions } = parseArgs([
|
|
143
|
+
'--isolated',
|
|
144
|
+
'screen docker',
|
|
145
|
+
'--',
|
|
146
|
+
'test',
|
|
147
|
+
]);
|
|
148
|
+
expect(hasStackedIsolation(wrapperOptions)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return false for single level', () => {
|
|
152
|
+
const { wrapperOptions } = parseArgs([
|
|
153
|
+
'--isolated',
|
|
154
|
+
'docker',
|
|
155
|
+
'--',
|
|
156
|
+
'test',
|
|
157
|
+
]);
|
|
158
|
+
expect(hasStackedIsolation(wrapperOptions)).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return false for no isolation', () => {
|
|
162
|
+
const { wrapperOptions } = parseArgs(['echo', 'hello']);
|
|
163
|
+
// When no isolation, isolatedStack is null, so hasStackedIsolation returns falsy
|
|
164
|
+
expect(hasStackedIsolation(wrapperOptions)).toBeFalsy();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('Isolation Stacking - Validation', () => {
|
|
170
|
+
describe('validateOptions', () => {
|
|
171
|
+
it('should validate single backend (backward compatible)', () => {
|
|
172
|
+
const options = {
|
|
173
|
+
isolated: 'docker',
|
|
174
|
+
isolatedStack: ['docker'],
|
|
175
|
+
};
|
|
176
|
+
expect(() => validateOptions(options)).not.toThrow();
|
|
177
|
+
// Should apply default image
|
|
178
|
+
expect(options.imageStack[0]).toBeDefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should validate multi-level stack', () => {
|
|
182
|
+
const options = {
|
|
183
|
+
isolated: 'screen',
|
|
184
|
+
isolatedStack: ['screen', 'ssh', 'docker'],
|
|
185
|
+
endpointStack: [null, 'user@host', null],
|
|
186
|
+
};
|
|
187
|
+
expect(() => validateOptions(options)).not.toThrow();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should throw on invalid backend in stack', () => {
|
|
191
|
+
const options = {
|
|
192
|
+
isolated: 'screen',
|
|
193
|
+
isolatedStack: ['screen', 'invalid', 'docker'],
|
|
194
|
+
};
|
|
195
|
+
expect(() => validateOptions(options)).toThrow(/Invalid isolation/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should throw on missing SSH endpoint', () => {
|
|
199
|
+
const options = {
|
|
200
|
+
isolated: 'ssh',
|
|
201
|
+
isolatedStack: ['ssh'],
|
|
202
|
+
endpointStack: [null],
|
|
203
|
+
};
|
|
204
|
+
expect(() => validateOptions(options)).toThrow(/requires --endpoint/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should throw on image/stack length mismatch', () => {
|
|
208
|
+
const options = {
|
|
209
|
+
isolated: 'screen',
|
|
210
|
+
isolatedStack: ['screen', 'ssh', 'docker'],
|
|
211
|
+
imageStack: [null, 'ubuntu:22.04'], // Only 2, should be 3
|
|
212
|
+
endpointStack: [null, 'user@host', null],
|
|
213
|
+
};
|
|
214
|
+
expect(() => validateOptions(options)).toThrow(/value\(s\)/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should throw on depth exceeding limit', () => {
|
|
218
|
+
const tooDeep = Array(MAX_ISOLATION_DEPTH + 1).fill('screen');
|
|
219
|
+
const options = {
|
|
220
|
+
isolated: 'screen',
|
|
221
|
+
isolatedStack: tooDeep,
|
|
222
|
+
};
|
|
223
|
+
expect(() => validateOptions(options)).toThrow(/too deep/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should distribute single image to all levels', () => {
|
|
227
|
+
const options = {
|
|
228
|
+
isolated: 'screen',
|
|
229
|
+
isolatedStack: ['screen', 'docker', 'docker'],
|
|
230
|
+
image: 'ubuntu:22.04',
|
|
231
|
+
};
|
|
232
|
+
validateOptions(options);
|
|
233
|
+
expect(options.imageStack).toEqual([
|
|
234
|
+
'ubuntu:22.04',
|
|
235
|
+
'ubuntu:22.04',
|
|
236
|
+
'ubuntu:22.04',
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should apply default docker image for each docker level', () => {
|
|
241
|
+
const options = {
|
|
242
|
+
isolated: 'docker',
|
|
243
|
+
isolatedStack: ['docker'],
|
|
244
|
+
};
|
|
245
|
+
validateOptions(options);
|
|
246
|
+
expect(options.imageStack[0]).toBeDefined();
|
|
247
|
+
expect(options.imageStack[0]).toContain(':'); // Should have image:tag format
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should throw if image provided but no docker in stack', () => {
|
|
251
|
+
const options = {
|
|
252
|
+
isolated: 'screen',
|
|
253
|
+
isolatedStack: ['screen', 'tmux'],
|
|
254
|
+
image: 'ubuntu:22.04',
|
|
255
|
+
};
|
|
256
|
+
expect(() => validateOptions(options)).toThrow(/docker/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should throw if endpoint provided but no ssh in stack', () => {
|
|
260
|
+
const options = {
|
|
261
|
+
isolated: 'screen',
|
|
262
|
+
isolatedStack: ['screen', 'docker'],
|
|
263
|
+
endpoint: 'user@host',
|
|
264
|
+
imageStack: [null, 'ubuntu:22.04'],
|
|
265
|
+
};
|
|
266
|
+
expect(() => validateOptions(options)).toThrow(/ssh/);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('Isolation Stacking - Backward Compatibility', () => {
|
|
272
|
+
it('should work with existing docker command', () => {
|
|
273
|
+
const result = parseArgs([
|
|
274
|
+
'--isolated',
|
|
275
|
+
'docker',
|
|
276
|
+
'--image',
|
|
277
|
+
'node:18',
|
|
278
|
+
'--',
|
|
279
|
+
'npm',
|
|
280
|
+
'test',
|
|
281
|
+
]);
|
|
282
|
+
expect(result.wrapperOptions.isolated).toBe('docker');
|
|
283
|
+
expect(result.wrapperOptions.image).toBe('node:18');
|
|
284
|
+
expect(result.wrapperOptions.isolatedStack).toEqual(['docker']);
|
|
285
|
+
expect(result.command).toBe('npm test');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should work with existing ssh command', () => {
|
|
289
|
+
const result = parseArgs([
|
|
290
|
+
'--isolated',
|
|
291
|
+
'ssh',
|
|
292
|
+
'--endpoint',
|
|
293
|
+
'user@server',
|
|
294
|
+
'--',
|
|
295
|
+
'ls',
|
|
296
|
+
'-la',
|
|
297
|
+
]);
|
|
298
|
+
expect(result.wrapperOptions.isolated).toBe('ssh');
|
|
299
|
+
expect(result.wrapperOptions.endpoint).toBe('user@server');
|
|
300
|
+
expect(result.wrapperOptions.isolatedStack).toEqual(['ssh']);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should work with existing screen command', () => {
|
|
304
|
+
const result = parseArgs([
|
|
305
|
+
'--isolated',
|
|
306
|
+
'screen',
|
|
307
|
+
'--detached',
|
|
308
|
+
'--keep-alive',
|
|
309
|
+
'--',
|
|
310
|
+
'long-running-task',
|
|
311
|
+
]);
|
|
312
|
+
expect(result.wrapperOptions.isolated).toBe('screen');
|
|
313
|
+
expect(result.wrapperOptions.detached).toBe(true);
|
|
314
|
+
expect(result.wrapperOptions.keepAlive).toBe(true);
|
|
315
|
+
expect(result.wrapperOptions.isolatedStack).toEqual(['screen']);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should work with -i shorthand', () => {
|
|
319
|
+
const result = parseArgs(['-i', 'tmux', '--', 'vim']);
|
|
320
|
+
expect(result.wrapperOptions.isolated).toBe('tmux');
|
|
321
|
+
expect(result.wrapperOptions.isolatedStack).toEqual(['tmux']);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should work with attached mode', () => {
|
|
325
|
+
const result = parseArgs([
|
|
326
|
+
'--isolated',
|
|
327
|
+
'docker',
|
|
328
|
+
'--attached',
|
|
329
|
+
'--image',
|
|
330
|
+
'alpine',
|
|
331
|
+
'--',
|
|
332
|
+
'sh',
|
|
333
|
+
]);
|
|
334
|
+
expect(result.wrapperOptions.attached).toBe(true);
|
|
335
|
+
expect(result.wrapperOptions.isolated).toBe('docker');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should work with session name', () => {
|
|
339
|
+
const result = parseArgs([
|
|
340
|
+
'--isolated',
|
|
341
|
+
'screen',
|
|
342
|
+
'--session',
|
|
343
|
+
'my-session',
|
|
344
|
+
'--',
|
|
345
|
+
'bash',
|
|
346
|
+
]);
|
|
347
|
+
expect(result.wrapperOptions.session).toBe('my-session');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should work with session-id', () => {
|
|
351
|
+
const result = parseArgs([
|
|
352
|
+
'--isolated',
|
|
353
|
+
'docker',
|
|
354
|
+
'--image',
|
|
355
|
+
'alpine',
|
|
356
|
+
'--session-id',
|
|
357
|
+
'12345678-1234-4123-8123-123456789012',
|
|
358
|
+
'--',
|
|
359
|
+
'echo',
|
|
360
|
+
'hi',
|
|
361
|
+
]);
|
|
362
|
+
expect(result.wrapperOptions.sessionId).toBe(
|
|
363
|
+
'12345678-1234-4123-8123-123456789012'
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -533,13 +533,41 @@ describe('output-blocks module', () => {
|
|
|
533
533
|
// $ docker pull alpine:latest
|
|
534
534
|
// <empty line>
|
|
535
535
|
// <docker output>
|
|
536
|
+
// <empty line>
|
|
536
537
|
// ✓
|
|
537
538
|
// │
|
|
538
539
|
// $ echo hi
|
|
539
540
|
// <empty line>
|
|
540
541
|
// hi
|
|
542
|
+
// <empty line>
|
|
541
543
|
// ✓
|
|
542
544
|
});
|
|
545
|
+
|
|
546
|
+
it('output formatting follows visual continuity pattern', () => {
|
|
547
|
+
// Issue #73: All commands should have consistent formatting:
|
|
548
|
+
// 1. Command line ($ ...)
|
|
549
|
+
// 2. Empty line (visual separation)
|
|
550
|
+
// 3. Command output
|
|
551
|
+
// 4. Empty line (visual separation)
|
|
552
|
+
// 5. Result marker (✓ or ✗)
|
|
553
|
+
//
|
|
554
|
+
// This test documents the expected output structure that
|
|
555
|
+
// dockerPullImage and runInDocker should produce.
|
|
556
|
+
|
|
557
|
+
// Verify createCommandLine produces the correct format
|
|
558
|
+
const commandLine = createCommandLine('docker pull alpine:latest');
|
|
559
|
+
expect(commandLine).toBe('$ docker pull alpine:latest');
|
|
560
|
+
|
|
561
|
+
// Verify createVirtualCommandBlock matches
|
|
562
|
+
const virtualCommandLine = createVirtualCommandBlock(
|
|
563
|
+
'docker pull alpine:latest'
|
|
564
|
+
);
|
|
565
|
+
expect(virtualCommandLine).toBe(commandLine);
|
|
566
|
+
|
|
567
|
+
// Verify result markers
|
|
568
|
+
expect(createVirtualCommandResult(true)).toBe('✓');
|
|
569
|
+
expect(createVirtualCommandResult(false)).toBe('✗');
|
|
570
|
+
});
|
|
543
571
|
});
|
|
544
572
|
});
|
|
545
573
|
|