start-command 0.20.4 → 0.22.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 +65 -0
- package/package.json +1 -1
- package/src/lib/args-parser.js +255 -45
- package/src/lib/command-builder.js +134 -0
- package/src/lib/isolation-log-utils.js +147 -0
- package/src/lib/isolation.js +166 -139
- package/src/lib/sequence-parser.js +231 -0
- package/test/args-parser-shell.test.js +165 -0
- package/test/args-parser.test.js +8 -6
- package/test/isolation-stacking.test.js +366 -0
- package/test/isolation.test.js +64 -0
- package/test/sequence-parser.test.js +237 -0
- package/test/user-manager.test.js +1 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for shell option in the argument parser
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { describe, it } = require('node:test');
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const { parseArgs, VALID_SHELLS } = require('../src/lib/args-parser');
|
|
9
|
+
|
|
10
|
+
describe('shell option', () => {
|
|
11
|
+
it('should default shell to auto', () => {
|
|
12
|
+
const result = parseArgs(['echo', 'hello']);
|
|
13
|
+
assert.strictEqual(result.wrapperOptions.shell, 'auto');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should parse --shell bash', () => {
|
|
17
|
+
const result = parseArgs([
|
|
18
|
+
'--isolated',
|
|
19
|
+
'docker',
|
|
20
|
+
'--shell',
|
|
21
|
+
'bash',
|
|
22
|
+
'--',
|
|
23
|
+
'npm',
|
|
24
|
+
'test',
|
|
25
|
+
]);
|
|
26
|
+
assert.strictEqual(result.wrapperOptions.shell, 'bash');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should parse --shell zsh', () => {
|
|
30
|
+
const result = parseArgs([
|
|
31
|
+
'--isolated',
|
|
32
|
+
'docker',
|
|
33
|
+
'--shell',
|
|
34
|
+
'zsh',
|
|
35
|
+
'--',
|
|
36
|
+
'npm',
|
|
37
|
+
'test',
|
|
38
|
+
]);
|
|
39
|
+
assert.strictEqual(result.wrapperOptions.shell, 'zsh');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should parse --shell sh', () => {
|
|
43
|
+
const result = parseArgs([
|
|
44
|
+
'--isolated',
|
|
45
|
+
'docker',
|
|
46
|
+
'--shell',
|
|
47
|
+
'sh',
|
|
48
|
+
'--',
|
|
49
|
+
'npm',
|
|
50
|
+
'test',
|
|
51
|
+
]);
|
|
52
|
+
assert.strictEqual(result.wrapperOptions.shell, 'sh');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should parse --shell auto', () => {
|
|
56
|
+
const result = parseArgs([
|
|
57
|
+
'--isolated',
|
|
58
|
+
'docker',
|
|
59
|
+
'--shell',
|
|
60
|
+
'auto',
|
|
61
|
+
'--',
|
|
62
|
+
'npm',
|
|
63
|
+
'test',
|
|
64
|
+
]);
|
|
65
|
+
assert.strictEqual(result.wrapperOptions.shell, 'auto');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should parse --shell=value format', () => {
|
|
69
|
+
const result = parseArgs([
|
|
70
|
+
'--isolated',
|
|
71
|
+
'docker',
|
|
72
|
+
'--shell=bash',
|
|
73
|
+
'--',
|
|
74
|
+
'npm',
|
|
75
|
+
'test',
|
|
76
|
+
]);
|
|
77
|
+
assert.strictEqual(result.wrapperOptions.shell, 'bash');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should normalize shell to lowercase', () => {
|
|
81
|
+
const result = parseArgs([
|
|
82
|
+
'--isolated',
|
|
83
|
+
'docker',
|
|
84
|
+
'--shell',
|
|
85
|
+
'BASH',
|
|
86
|
+
'--',
|
|
87
|
+
'npm',
|
|
88
|
+
'test',
|
|
89
|
+
]);
|
|
90
|
+
assert.strictEqual(result.wrapperOptions.shell, 'bash');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should throw error for missing shell argument', () => {
|
|
94
|
+
assert.throws(() => {
|
|
95
|
+
parseArgs(['--isolated', 'docker', '--shell']);
|
|
96
|
+
}, /requires a shell argument/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should throw error for invalid shell', () => {
|
|
100
|
+
assert.throws(() => {
|
|
101
|
+
parseArgs([
|
|
102
|
+
'--isolated',
|
|
103
|
+
'docker',
|
|
104
|
+
'--shell',
|
|
105
|
+
'fish',
|
|
106
|
+
'--',
|
|
107
|
+
'echo',
|
|
108
|
+
'hi',
|
|
109
|
+
]);
|
|
110
|
+
}, /Invalid shell/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should list valid shells in error message', () => {
|
|
114
|
+
try {
|
|
115
|
+
parseArgs([
|
|
116
|
+
'--isolated',
|
|
117
|
+
'docker',
|
|
118
|
+
'--shell',
|
|
119
|
+
'invalid',
|
|
120
|
+
'--',
|
|
121
|
+
'echo',
|
|
122
|
+
'test',
|
|
123
|
+
]);
|
|
124
|
+
assert.fail('Should have thrown an error');
|
|
125
|
+
} catch (err) {
|
|
126
|
+
for (const shell of VALID_SHELLS) {
|
|
127
|
+
assert.ok(err.message.includes(shell), `Error should mention ${shell}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should work with ssh isolation', () => {
|
|
133
|
+
const result = parseArgs([
|
|
134
|
+
'--isolated',
|
|
135
|
+
'ssh',
|
|
136
|
+
'--endpoint',
|
|
137
|
+
'user@host',
|
|
138
|
+
'--shell',
|
|
139
|
+
'bash',
|
|
140
|
+
'--',
|
|
141
|
+
'echo',
|
|
142
|
+
'hi',
|
|
143
|
+
]);
|
|
144
|
+
assert.strictEqual(result.wrapperOptions.shell, 'bash');
|
|
145
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'ssh');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('VALID_SHELLS', () => {
|
|
150
|
+
it('should include bash', () => {
|
|
151
|
+
assert.ok(VALID_SHELLS.includes('bash'));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should include zsh', () => {
|
|
155
|
+
assert.ok(VALID_SHELLS.includes('zsh'));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should include sh', () => {
|
|
159
|
+
assert.ok(VALID_SHELLS.includes('sh'));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should include auto', () => {
|
|
163
|
+
assert.ok(VALID_SHELLS.includes('auto'));
|
|
164
|
+
});
|
|
165
|
+
});
|
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', () => {
|
|
@@ -927,3 +927,5 @@ describe('cleanup options', () => {
|
|
|
927
927
|
assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
|
|
928
928
|
});
|
|
929
929
|
});
|
|
930
|
+
|
|
931
|
+
// Shell option tests moved to args-parser-shell.test.js
|