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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 694d85e: feat: Add shell auto-detection and --shell option for isolation environments
|
|
8
|
+
|
|
9
|
+
In docker/ssh and other applicable isolation environments, the shell is now
|
|
10
|
+
automatically detected in order of preference: `bash` → `zsh` → `sh`.
|
|
11
|
+
|
|
12
|
+
Previously, `/bin/sh` was hardcoded in Docker and SSH isolation, which prevented
|
|
13
|
+
access to tools like `nvm` that require bash. Now, the most feature-complete
|
|
14
|
+
available shell is used automatically.
|
|
15
|
+
|
|
16
|
+
Key features:
|
|
17
|
+
- Auto-detect best available shell in Docker containers and SSH hosts (`bash > zsh > sh`)
|
|
18
|
+
- New `--shell` option to force a specific shell (`auto`, `bash`, `zsh`, `sh`)
|
|
19
|
+
- Default mode is `auto` — no need to specify `--shell` for automatic detection
|
|
20
|
+
- `--shell` is passed through in isolation stacking
|
|
21
|
+
|
|
22
|
+
Example usage:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Auto-detect best shell (default behavior, no option needed)
|
|
26
|
+
$ --isolated docker --image node:20 -- nvm use 20
|
|
27
|
+
|
|
28
|
+
# Force bash explicitly
|
|
29
|
+
$ --isolated docker --image ubuntu:22.04 --shell bash -- echo $BASH_VERSION
|
|
30
|
+
|
|
31
|
+
# Use sh specifically
|
|
32
|
+
$ --isolated ssh --endpoint user@host --shell sh -- echo hello
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 0.21.0
|
|
36
|
+
|
|
37
|
+
### Minor Changes
|
|
38
|
+
|
|
39
|
+
- bd8fc93: feat: Add isolation stacking support
|
|
40
|
+
|
|
41
|
+
Added support for stacking multiple isolation environments in sequence,
|
|
42
|
+
allowing complex isolation chains like:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
$ echo hi --isolated "screen ssh tmux docker"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Key features:
|
|
49
|
+
- Space-separated sequences for `--isolated`, `--image`, and `--endpoint` options
|
|
50
|
+
- Underscore (`_`) placeholder for "default/skip" values in option sequences
|
|
51
|
+
- Recursive execution where each level invokes `$` with remaining levels
|
|
52
|
+
- Maximum isolation depth of 7 levels (prevents infinite recursion)
|
|
53
|
+
|
|
54
|
+
Example usage:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# SSH to remote host, then run in Docker
|
|
58
|
+
$ cmd --isolated "ssh docker" --endpoint "user@host _" --image "_ node:20"
|
|
59
|
+
|
|
60
|
+
# Create screen session, SSH to host, start tmux, run in Docker
|
|
61
|
+
$ cmd --isolated "screen ssh tmux docker" --endpoint "_ user@host _ _" --image "_ _ _ node:20"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Backward compatible: All existing single-level isolation commands work unchanged.
|
|
65
|
+
|
|
66
|
+
Fixes #77
|
|
67
|
+
|
|
3
68
|
## 0.20.4
|
|
4
69
|
|
|
5
70
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/args-parser.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* --keep-user Keep isolated user after command completes (don't delete)
|
|
17
17
|
* --keep-alive, -k Keep isolation environment alive after command exits
|
|
18
18
|
* --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
|
|
19
|
+
* --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
|
|
19
20
|
* --use-command-stream Use command-stream library for command execution (experimental)
|
|
20
21
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
21
22
|
* --output-format <format> Output format for status (links-notation, json, text)
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
28
|
+
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
27
29
|
|
|
28
30
|
// Debug mode from environment
|
|
29
31
|
const DEBUG =
|
|
@@ -34,6 +36,16 @@ const DEBUG =
|
|
|
34
36
|
*/
|
|
35
37
|
const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Valid shell options for --shell
|
|
41
|
+
*/
|
|
42
|
+
const VALID_SHELLS = ['auto', 'bash', 'zsh', 'sh'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Maximum depth for isolation stacking
|
|
46
|
+
*/
|
|
47
|
+
const MAX_ISOLATION_DEPTH = 7;
|
|
48
|
+
|
|
37
49
|
/**
|
|
38
50
|
* Valid output formats for --status
|
|
39
51
|
*/
|
|
@@ -73,6 +85,63 @@ function generateUUID() {
|
|
|
73
85
|
}
|
|
74
86
|
}
|
|
75
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Parse --isolated value, handling both single values and sequences
|
|
90
|
+
* @param {string} value - Isolation value (e.g., "docker" or "screen ssh docker")
|
|
91
|
+
* @param {object} options - Options object to populate
|
|
92
|
+
*/
|
|
93
|
+
function parseIsolatedValue(value, options) {
|
|
94
|
+
if (isSequence(value)) {
|
|
95
|
+
// Multi-value sequence (e.g., "screen ssh docker")
|
|
96
|
+
const backends = parseSequence(value).map((v) =>
|
|
97
|
+
v ? v.toLowerCase() : null
|
|
98
|
+
);
|
|
99
|
+
options.isolatedStack = backends;
|
|
100
|
+
options.isolated = backends[0]; // Current level
|
|
101
|
+
} else {
|
|
102
|
+
// Single value (backward compatible)
|
|
103
|
+
const backend = value.toLowerCase();
|
|
104
|
+
options.isolated = backend;
|
|
105
|
+
options.isolatedStack = [backend];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse --image value, handling both single values and sequences
|
|
111
|
+
* @param {string} value - Image value (e.g., "ubuntu:22.04" or "_ _ ubuntu:22.04")
|
|
112
|
+
* @param {object} options - Options object to populate
|
|
113
|
+
*/
|
|
114
|
+
function parseImageValue(value, options) {
|
|
115
|
+
if (isSequence(value)) {
|
|
116
|
+
// Multi-value sequence with placeholders
|
|
117
|
+
const images = parseSequence(value);
|
|
118
|
+
options.imageStack = images;
|
|
119
|
+
options.image = images[0]; // Current level
|
|
120
|
+
} else {
|
|
121
|
+
// Single value - will be distributed later during validation
|
|
122
|
+
options.image = value;
|
|
123
|
+
options.imageStack = null; // Will be populated during validation
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse --endpoint value, handling both single values and sequences
|
|
129
|
+
* @param {string} value - Endpoint value (e.g., "user@host" or "_ user@host1 _ user@host2")
|
|
130
|
+
* @param {object} options - Options object to populate
|
|
131
|
+
*/
|
|
132
|
+
function parseEndpointValue(value, options) {
|
|
133
|
+
if (isSequence(value)) {
|
|
134
|
+
// Multi-value sequence with placeholders
|
|
135
|
+
const endpoints = parseSequence(value);
|
|
136
|
+
options.endpointStack = endpoints;
|
|
137
|
+
options.endpoint = endpoints[0]; // Current level
|
|
138
|
+
} else {
|
|
139
|
+
// Single value - will be distributed later during validation
|
|
140
|
+
options.endpoint = value;
|
|
141
|
+
options.endpointStack = null; // Will be populated during validation
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
76
145
|
/**
|
|
77
146
|
* Parse command line arguments into wrapper options and command
|
|
78
147
|
* @param {string[]} args - Array of command line arguments
|
|
@@ -80,18 +149,23 @@ function generateUUID() {
|
|
|
80
149
|
*/
|
|
81
150
|
function parseArgs(args) {
|
|
82
151
|
const wrapperOptions = {
|
|
83
|
-
isolated: null, // Isolation environment: screen, tmux, docker, ssh
|
|
152
|
+
isolated: null, // Isolation environment: screen, tmux, docker, ssh (current level)
|
|
153
|
+
isolatedStack: null, // Full isolation stack for multi-level isolation (e.g., ["screen", "ssh", "docker"])
|
|
84
154
|
attached: false, // Run in attached mode
|
|
85
155
|
detached: false, // Run in detached mode
|
|
86
|
-
session: null, // Session name
|
|
156
|
+
session: null, // Session name (current level)
|
|
157
|
+
sessionStack: null, // Session names for each level
|
|
87
158
|
sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided
|
|
88
|
-
image: null, // Docker image
|
|
89
|
-
|
|
159
|
+
image: null, // Docker image (current level)
|
|
160
|
+
imageStack: null, // Docker images for each level (with nulls for non-docker levels)
|
|
161
|
+
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
162
|
+
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
90
163
|
user: false, // Create isolated user
|
|
91
164
|
userName: null, // Optional custom username for isolated user
|
|
92
165
|
keepUser: false, // Keep isolated user after command completes (don't delete)
|
|
93
166
|
keepAlive: false, // Keep environment alive after command exits
|
|
94
167
|
autoRemoveDockerContainer: false, // Auto-remove docker container after exit
|
|
168
|
+
shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
|
|
95
169
|
useCommandStream: false, // Use command-stream library for command execution
|
|
96
170
|
status: null, // UUID to show status for
|
|
97
171
|
outputFormat: null, // Output format for status (links-notation, json, text)
|
|
@@ -175,18 +249,20 @@ function parseOption(args, index, options) {
|
|
|
175
249
|
// --isolated or -i
|
|
176
250
|
if (arg === '--isolated' || arg === '-i') {
|
|
177
251
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
178
|
-
|
|
252
|
+
const value = args[index + 1];
|
|
253
|
+
parseIsolatedValue(value, options);
|
|
179
254
|
return 2;
|
|
180
255
|
} else {
|
|
181
256
|
throw new Error(
|
|
182
|
-
`Option ${arg} requires a backend argument (screen, tmux, docker)`
|
|
257
|
+
`Option ${arg} requires a backend argument (screen, tmux, docker, ssh)`
|
|
183
258
|
);
|
|
184
259
|
}
|
|
185
260
|
}
|
|
186
261
|
|
|
187
262
|
// --isolated=<value>
|
|
188
263
|
if (arg.startsWith('--isolated=')) {
|
|
189
|
-
|
|
264
|
+
const value = arg.split('=')[1];
|
|
265
|
+
parseIsolatedValue(value, options);
|
|
190
266
|
return 1;
|
|
191
267
|
}
|
|
192
268
|
|
|
@@ -218,10 +294,11 @@ function parseOption(args, index, options) {
|
|
|
218
294
|
return 1;
|
|
219
295
|
}
|
|
220
296
|
|
|
221
|
-
// --image (for docker)
|
|
297
|
+
// --image (for docker) - supports sequence for stacked isolation
|
|
222
298
|
if (arg === '--image') {
|
|
223
299
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
224
|
-
|
|
300
|
+
const value = args[index + 1];
|
|
301
|
+
parseImageValue(value, options);
|
|
225
302
|
return 2;
|
|
226
303
|
} else {
|
|
227
304
|
throw new Error(`Option ${arg} requires an image name argument`);
|
|
@@ -230,14 +307,16 @@ function parseOption(args, index, options) {
|
|
|
230
307
|
|
|
231
308
|
// --image=<value>
|
|
232
309
|
if (arg.startsWith('--image=')) {
|
|
233
|
-
|
|
310
|
+
const value = arg.split('=')[1];
|
|
311
|
+
parseImageValue(value, options);
|
|
234
312
|
return 1;
|
|
235
313
|
}
|
|
236
314
|
|
|
237
|
-
// --endpoint (for ssh)
|
|
315
|
+
// --endpoint (for ssh) - supports sequence for stacked isolation
|
|
238
316
|
if (arg === '--endpoint') {
|
|
239
317
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
240
|
-
|
|
318
|
+
const value = args[index + 1];
|
|
319
|
+
parseEndpointValue(value, options);
|
|
241
320
|
return 2;
|
|
242
321
|
} else {
|
|
243
322
|
throw new Error(`Option ${arg} requires an endpoint argument`);
|
|
@@ -246,7 +325,8 @@ function parseOption(args, index, options) {
|
|
|
246
325
|
|
|
247
326
|
// --endpoint=<value>
|
|
248
327
|
if (arg.startsWith('--endpoint=')) {
|
|
249
|
-
|
|
328
|
+
const value = arg.split('=')[1];
|
|
329
|
+
parseEndpointValue(value, options);
|
|
250
330
|
return 1;
|
|
251
331
|
}
|
|
252
332
|
|
|
@@ -291,6 +371,24 @@ function parseOption(args, index, options) {
|
|
|
291
371
|
return 1;
|
|
292
372
|
}
|
|
293
373
|
|
|
374
|
+
// --shell <shell>
|
|
375
|
+
if (arg === '--shell') {
|
|
376
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
377
|
+
options.shell = args[index + 1].toLowerCase();
|
|
378
|
+
return 2;
|
|
379
|
+
} else {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Option ${arg} requires a shell argument (auto, bash, zsh, sh)`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --shell=<value>
|
|
387
|
+
if (arg.startsWith('--shell=')) {
|
|
388
|
+
options.shell = arg.split('=')[1].toLowerCase();
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
|
|
294
392
|
// --use-command-stream
|
|
295
393
|
if (arg === '--use-command-stream') {
|
|
296
394
|
options.useCommandStream = true;
|
|
@@ -375,23 +473,137 @@ function validateOptions(options) {
|
|
|
375
473
|
);
|
|
376
474
|
}
|
|
377
475
|
|
|
378
|
-
// Validate isolation environment
|
|
476
|
+
// Validate isolation environment (with stacking support)
|
|
379
477
|
if (options.isolated !== null) {
|
|
380
|
-
|
|
478
|
+
const stack = options.isolatedStack || [options.isolated];
|
|
479
|
+
const stackDepth = stack.length;
|
|
480
|
+
|
|
481
|
+
// Check depth limit
|
|
482
|
+
if (stackDepth > MAX_ISOLATION_DEPTH) {
|
|
381
483
|
throw new Error(
|
|
382
|
-
`
|
|
484
|
+
`Isolation stack too deep: ${stackDepth} levels (max: ${MAX_ISOLATION_DEPTH})`
|
|
383
485
|
);
|
|
384
486
|
}
|
|
385
487
|
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
488
|
+
// Validate each backend in the stack
|
|
489
|
+
for (const backend of stack) {
|
|
490
|
+
if (backend && !VALID_BACKENDS.includes(backend)) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`Invalid isolation environment: "${backend}". Valid options are: ${VALID_BACKENDS.join(', ')}`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Distribute single option values across stack if needed
|
|
498
|
+
if (options.image && !options.imageStack) {
|
|
499
|
+
// Single image value - replicate for all levels
|
|
500
|
+
options.imageStack = Array(stackDepth).fill(options.image);
|
|
389
501
|
}
|
|
390
502
|
|
|
391
|
-
|
|
392
|
-
|
|
503
|
+
if (options.endpoint && !options.endpointStack) {
|
|
504
|
+
// Single endpoint value - replicate for all levels
|
|
505
|
+
options.endpointStack = Array(stackDepth).fill(options.endpoint);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Validate stack lengths match
|
|
509
|
+
if (options.imageStack && options.imageStack.length !== stackDepth) {
|
|
393
510
|
throw new Error(
|
|
394
|
-
|
|
511
|
+
`--image has ${options.imageStack.length} value(s) but isolation stack has ${stackDepth} level(s). ` +
|
|
512
|
+
`Use underscores (_) as placeholders for levels that don't need this option.`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (options.endpointStack && options.endpointStack.length !== stackDepth) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`--endpoint has ${options.endpointStack.length} value(s) but isolation stack has ${stackDepth} level(s). ` +
|
|
519
|
+
`Use underscores (_) as placeholders for levels that don't need this option.`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Validate each level has required options
|
|
524
|
+
for (let i = 0; i < stackDepth; i++) {
|
|
525
|
+
const backend = stack[i];
|
|
526
|
+
|
|
527
|
+
// Docker uses --image or defaults to OS-matched image
|
|
528
|
+
if (backend === 'docker') {
|
|
529
|
+
const image = options.imageStack
|
|
530
|
+
? options.imageStack[i]
|
|
531
|
+
: options.image;
|
|
532
|
+
if (!image) {
|
|
533
|
+
// Apply default image
|
|
534
|
+
if (!options.imageStack) {
|
|
535
|
+
options.imageStack = Array(stackDepth).fill(null);
|
|
536
|
+
}
|
|
537
|
+
options.imageStack[i] = getDefaultDockerImage();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// SSH requires --endpoint
|
|
542
|
+
if (backend === 'ssh') {
|
|
543
|
+
const endpoint = options.endpointStack
|
|
544
|
+
? options.endpointStack[i]
|
|
545
|
+
: options.endpoint;
|
|
546
|
+
if (!endpoint) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`SSH isolation at level ${i + 1} requires --endpoint option. ` +
|
|
549
|
+
`Use a sequence like --endpoint "_ user@host _" to specify endpoints for specific levels.`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Set current level values for backward compatibility
|
|
556
|
+
options.image = options.imageStack ? options.imageStack[0] : options.image;
|
|
557
|
+
options.endpoint = options.endpointStack
|
|
558
|
+
? options.endpointStack[0]
|
|
559
|
+
: options.endpoint;
|
|
560
|
+
|
|
561
|
+
// Validate option compatibility with current level (for backward compatible error messages)
|
|
562
|
+
const currentBackend = stack[0];
|
|
563
|
+
|
|
564
|
+
// Image is only valid if stack contains docker
|
|
565
|
+
if (options.image && !stack.includes('docker')) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
'--image option is only valid when isolation stack includes docker'
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Endpoint is only valid if stack contains ssh
|
|
572
|
+
if (options.endpoint && !stack.includes('ssh')) {
|
|
573
|
+
throw new Error(
|
|
574
|
+
'--endpoint option is only valid when isolation stack includes ssh'
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Auto-remove-docker-container is only valid with docker in stack
|
|
579
|
+
if (options.autoRemoveDockerContainer && !stack.includes('docker')) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
'--auto-remove-docker-container option is only valid when isolation stack includes docker'
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// User isolation is not supported with Docker as first level
|
|
586
|
+
if (options.user && currentBackend === 'docker') {
|
|
587
|
+
throw new Error(
|
|
588
|
+
'--isolated-user is not supported with Docker as the first isolation level. ' +
|
|
589
|
+
'Docker uses its own user namespace for isolation.'
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
// Validate options that require isolation when no isolation is specified
|
|
594
|
+
if (options.autoRemoveDockerContainer) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
'--auto-remove-docker-container option is only valid when isolation stack includes docker'
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (options.image) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
'--image option is only valid when isolation stack includes docker'
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (options.endpoint) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
'--endpoint option is only valid when isolation stack includes ssh'
|
|
395
607
|
);
|
|
396
608
|
}
|
|
397
609
|
}
|
|
@@ -401,36 +613,13 @@ function validateOptions(options) {
|
|
|
401
613
|
throw new Error('--session option is only valid with --isolated');
|
|
402
614
|
}
|
|
403
615
|
|
|
404
|
-
// Image is only valid with docker
|
|
405
|
-
if (options.image && options.isolated !== 'docker') {
|
|
406
|
-
throw new Error('--image option is only valid with --isolated docker');
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Endpoint is only valid with ssh
|
|
410
|
-
if (options.endpoint && options.isolated !== 'ssh') {
|
|
411
|
-
throw new Error('--endpoint option is only valid with --isolated ssh');
|
|
412
|
-
}
|
|
413
|
-
|
|
414
616
|
// Keep-alive is only valid with isolation
|
|
415
617
|
if (options.keepAlive && !options.isolated) {
|
|
416
618
|
throw new Error('--keep-alive option is only valid with --isolated');
|
|
417
619
|
}
|
|
418
620
|
|
|
419
|
-
// Auto-remove-docker-container is only valid with docker isolation
|
|
420
|
-
if (options.autoRemoveDockerContainer && options.isolated !== 'docker') {
|
|
421
|
-
throw new Error(
|
|
422
|
-
'--auto-remove-docker-container option is only valid with --isolated docker'
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
621
|
// User isolation validation
|
|
427
622
|
if (options.user) {
|
|
428
|
-
// User isolation is not supported with Docker (Docker has its own user mechanism)
|
|
429
|
-
if (options.isolated === 'docker') {
|
|
430
|
-
throw new Error(
|
|
431
|
-
'--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation.'
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
623
|
// Validate custom username if provided
|
|
435
624
|
if (options.userName) {
|
|
436
625
|
if (!/^[a-zA-Z0-9_-]+$/.test(options.userName)) {
|
|
@@ -465,6 +654,15 @@ function validateOptions(options) {
|
|
|
465
654
|
throw new Error('--output-format option is only valid with --status');
|
|
466
655
|
}
|
|
467
656
|
|
|
657
|
+
// Validate shell option
|
|
658
|
+
if (options.shell !== null && options.shell !== undefined) {
|
|
659
|
+
if (!VALID_SHELLS.includes(options.shell)) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
`Invalid shell: "${options.shell}". Valid options are: ${VALID_SHELLS.join(', ')}`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
468
666
|
// Validate session ID is a valid UUID if provided
|
|
469
667
|
if (options.sessionId !== null && options.sessionId !== undefined) {
|
|
470
668
|
if (!isValidUUID(options.sessionId)) {
|
|
@@ -509,14 +707,26 @@ function getEffectiveMode(options) {
|
|
|
509
707
|
return 'attached';
|
|
510
708
|
}
|
|
511
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Check if isolation stack has multiple levels
|
|
712
|
+
* @param {object} options - Parsed wrapper options
|
|
713
|
+
* @returns {boolean} True if multiple isolation levels
|
|
714
|
+
*/
|
|
715
|
+
function hasStackedIsolation(options) {
|
|
716
|
+
return options.isolatedStack && options.isolatedStack.length > 1;
|
|
717
|
+
}
|
|
718
|
+
|
|
512
719
|
module.exports = {
|
|
513
720
|
parseArgs,
|
|
514
721
|
validateOptions,
|
|
515
722
|
generateSessionName,
|
|
516
723
|
hasIsolation,
|
|
724
|
+
hasStackedIsolation,
|
|
517
725
|
getEffectiveMode,
|
|
518
726
|
isValidUUID,
|
|
519
727
|
generateUUID,
|
|
520
728
|
VALID_BACKENDS,
|
|
521
729
|
VALID_OUTPUT_FORMATS,
|
|
730
|
+
VALID_SHELLS,
|
|
731
|
+
MAX_ISOLATION_DEPTH,
|
|
522
732
|
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Builder for Isolation Stacking
|
|
3
|
+
*
|
|
4
|
+
* Builds the command to execute at each isolation level,
|
|
5
|
+
* including the recursive $ invocation for nested levels.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { formatSequence } = require('./sequence-parser');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build command for next isolation level
|
|
12
|
+
* If more levels remain, builds a recursive $ command
|
|
13
|
+
* If this is the last level, returns the actual command
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options - Current wrapper options
|
|
16
|
+
* @param {string} command - User command to execute
|
|
17
|
+
* @returns {string} Command to execute at current level
|
|
18
|
+
*/
|
|
19
|
+
function buildNextLevelCommand(options, command) {
|
|
20
|
+
// If no more isolation levels, execute actual command
|
|
21
|
+
if (!options.isolatedStack || options.isolatedStack.length <= 1) {
|
|
22
|
+
return command;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Build recursive $ command for remaining levels
|
|
26
|
+
const parts = ['$'];
|
|
27
|
+
|
|
28
|
+
// Remaining isolation stack (skip first which is current level)
|
|
29
|
+
const remainingStack = options.isolatedStack.slice(1);
|
|
30
|
+
parts.push(`--isolated "${remainingStack.join(' ')}"`);
|
|
31
|
+
|
|
32
|
+
// Shift option values and add if non-empty
|
|
33
|
+
if (options.imageStack && options.imageStack.length > 1) {
|
|
34
|
+
const remainingImages = options.imageStack.slice(1);
|
|
35
|
+
const imageStr = formatSequence(remainingImages);
|
|
36
|
+
if (imageStr && imageStr !== '_'.repeat(remainingImages.length)) {
|
|
37
|
+
parts.push(`--image "${imageStr}"`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.endpointStack && options.endpointStack.length > 1) {
|
|
42
|
+
const remainingEndpoints = options.endpointStack.slice(1);
|
|
43
|
+
const endpointStr = formatSequence(remainingEndpoints);
|
|
44
|
+
if (endpointStr) {
|
|
45
|
+
parts.push(`--endpoint "${endpointStr}"`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (options.sessionStack && options.sessionStack.length > 1) {
|
|
50
|
+
const remainingSessions = options.sessionStack.slice(1);
|
|
51
|
+
const sessionStr = formatSequence(remainingSessions);
|
|
52
|
+
if (sessionStr && sessionStr !== '_'.repeat(remainingSessions.length)) {
|
|
53
|
+
parts.push(`--session "${sessionStr}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pass through global flags
|
|
58
|
+
if (options.detached) {
|
|
59
|
+
parts.push('--detached');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (options.keepAlive) {
|
|
63
|
+
parts.push('--keep-alive');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.sessionId) {
|
|
67
|
+
parts.push(`--session-id ${options.sessionId}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (options.autoRemoveDockerContainer) {
|
|
71
|
+
parts.push('--auto-remove-docker-container');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.shell && options.shell !== 'auto') {
|
|
75
|
+
parts.push(`--shell ${options.shell}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Separator and command
|
|
79
|
+
parts.push('--');
|
|
80
|
+
parts.push(command);
|
|
81
|
+
|
|
82
|
+
return parts.join(' ');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Escape a command for safe execution in a shell context
|
|
87
|
+
* @param {string} cmd - Command to escape
|
|
88
|
+
* @returns {string} Escaped command
|
|
89
|
+
*/
|
|
90
|
+
function escapeForShell(cmd) {
|
|
91
|
+
// For now, simple escaping - could be enhanced
|
|
92
|
+
return cmd.replace(/'/g, "'\\''");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if we're at the last isolation level
|
|
97
|
+
* @param {object} options - Wrapper options
|
|
98
|
+
* @returns {boolean} True if this is the last level
|
|
99
|
+
*/
|
|
100
|
+
function isLastLevel(options) {
|
|
101
|
+
return !options.isolatedStack || options.isolatedStack.length <= 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get current isolation backend from options
|
|
106
|
+
* @param {object} options - Wrapper options
|
|
107
|
+
* @returns {string|null} Current backend or null
|
|
108
|
+
*/
|
|
109
|
+
function getCurrentBackend(options) {
|
|
110
|
+
if (options.isolatedStack && options.isolatedStack.length > 0) {
|
|
111
|
+
return options.isolatedStack[0];
|
|
112
|
+
}
|
|
113
|
+
return options.isolated;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get option value for current level
|
|
118
|
+
* @param {(string|null)[]} stack - Option value stack
|
|
119
|
+
* @returns {string|null} Value for current level
|
|
120
|
+
*/
|
|
121
|
+
function getCurrentValue(stack) {
|
|
122
|
+
if (Array.isArray(stack) && stack.length > 0) {
|
|
123
|
+
return stack[0];
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
buildNextLevelCommand,
|
|
130
|
+
escapeForShell,
|
|
131
|
+
isLastLevel,
|
|
132
|
+
getCurrentBackend,
|
|
133
|
+
getCurrentValue,
|
|
134
|
+
};
|