start-command 0.20.4 → 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 +33 -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/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/sequence-parser.test.js +237 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.21.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bd8fc93: feat: Add isolation stacking support
|
|
8
|
+
|
|
9
|
+
Added support for stacking multiple isolation environments in sequence,
|
|
10
|
+
allowing complex isolation chains like:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
$ echo hi --isolated "screen ssh tmux docker"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Key features:
|
|
17
|
+
- Space-separated sequences for `--isolated`, `--image`, and `--endpoint` options
|
|
18
|
+
- Underscore (`_`) placeholder for "default/skip" values in option sequences
|
|
19
|
+
- Recursive execution where each level invokes `$` with remaining levels
|
|
20
|
+
- Maximum isolation depth of 7 levels (prevents infinite recursion)
|
|
21
|
+
|
|
22
|
+
Example usage:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# SSH to remote host, then run in Docker
|
|
26
|
+
$ cmd --isolated "ssh docker" --endpoint "user@host _" --image "_ node:20"
|
|
27
|
+
|
|
28
|
+
# Create screen session, SSH to host, start tmux, run in Docker
|
|
29
|
+
$ cmd --isolated "screen ssh tmux docker" --endpoint "_ user@host _ _" --image "_ _ _ node:20"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Backward compatible: All existing single-level isolation commands work unchanged.
|
|
33
|
+
|
|
34
|
+
Fixes #77
|
|
35
|
+
|
|
3
36
|
## 0.20.4
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/args-parser.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
27
|
+
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
27
28
|
|
|
28
29
|
// Debug mode from environment
|
|
29
30
|
const DEBUG =
|
|
@@ -34,6 +35,11 @@ const DEBUG =
|
|
|
34
35
|
*/
|
|
35
36
|
const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Maximum depth for isolation stacking
|
|
40
|
+
*/
|
|
41
|
+
const MAX_ISOLATION_DEPTH = 7;
|
|
42
|
+
|
|
37
43
|
/**
|
|
38
44
|
* Valid output formats for --status
|
|
39
45
|
*/
|
|
@@ -73,6 +79,63 @@ function generateUUID() {
|
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Parse --isolated value, handling both single values and sequences
|
|
84
|
+
* @param {string} value - Isolation value (e.g., "docker" or "screen ssh docker")
|
|
85
|
+
* @param {object} options - Options object to populate
|
|
86
|
+
*/
|
|
87
|
+
function parseIsolatedValue(value, options) {
|
|
88
|
+
if (isSequence(value)) {
|
|
89
|
+
// Multi-value sequence (e.g., "screen ssh docker")
|
|
90
|
+
const backends = parseSequence(value).map((v) =>
|
|
91
|
+
v ? v.toLowerCase() : null
|
|
92
|
+
);
|
|
93
|
+
options.isolatedStack = backends;
|
|
94
|
+
options.isolated = backends[0]; // Current level
|
|
95
|
+
} else {
|
|
96
|
+
// Single value (backward compatible)
|
|
97
|
+
const backend = value.toLowerCase();
|
|
98
|
+
options.isolated = backend;
|
|
99
|
+
options.isolatedStack = [backend];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse --image value, handling both single values and sequences
|
|
105
|
+
* @param {string} value - Image value (e.g., "ubuntu:22.04" or "_ _ ubuntu:22.04")
|
|
106
|
+
* @param {object} options - Options object to populate
|
|
107
|
+
*/
|
|
108
|
+
function parseImageValue(value, options) {
|
|
109
|
+
if (isSequence(value)) {
|
|
110
|
+
// Multi-value sequence with placeholders
|
|
111
|
+
const images = parseSequence(value);
|
|
112
|
+
options.imageStack = images;
|
|
113
|
+
options.image = images[0]; // Current level
|
|
114
|
+
} else {
|
|
115
|
+
// Single value - will be distributed later during validation
|
|
116
|
+
options.image = value;
|
|
117
|
+
options.imageStack = null; // Will be populated during validation
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse --endpoint value, handling both single values and sequences
|
|
123
|
+
* @param {string} value - Endpoint value (e.g., "user@host" or "_ user@host1 _ user@host2")
|
|
124
|
+
* @param {object} options - Options object to populate
|
|
125
|
+
*/
|
|
126
|
+
function parseEndpointValue(value, options) {
|
|
127
|
+
if (isSequence(value)) {
|
|
128
|
+
// Multi-value sequence with placeholders
|
|
129
|
+
const endpoints = parseSequence(value);
|
|
130
|
+
options.endpointStack = endpoints;
|
|
131
|
+
options.endpoint = endpoints[0]; // Current level
|
|
132
|
+
} else {
|
|
133
|
+
// Single value - will be distributed later during validation
|
|
134
|
+
options.endpoint = value;
|
|
135
|
+
options.endpointStack = null; // Will be populated during validation
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
76
139
|
/**
|
|
77
140
|
* Parse command line arguments into wrapper options and command
|
|
78
141
|
* @param {string[]} args - Array of command line arguments
|
|
@@ -80,13 +143,17 @@ function generateUUID() {
|
|
|
80
143
|
*/
|
|
81
144
|
function parseArgs(args) {
|
|
82
145
|
const wrapperOptions = {
|
|
83
|
-
isolated: null, // Isolation environment: screen, tmux, docker, ssh
|
|
146
|
+
isolated: null, // Isolation environment: screen, tmux, docker, ssh (current level)
|
|
147
|
+
isolatedStack: null, // Full isolation stack for multi-level isolation (e.g., ["screen", "ssh", "docker"])
|
|
84
148
|
attached: false, // Run in attached mode
|
|
85
149
|
detached: false, // Run in detached mode
|
|
86
|
-
session: null, // Session name
|
|
150
|
+
session: null, // Session name (current level)
|
|
151
|
+
sessionStack: null, // Session names for each level
|
|
87
152
|
sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided
|
|
88
|
-
image: null, // Docker image
|
|
89
|
-
|
|
153
|
+
image: null, // Docker image (current level)
|
|
154
|
+
imageStack: null, // Docker images for each level (with nulls for non-docker levels)
|
|
155
|
+
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
156
|
+
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
90
157
|
user: false, // Create isolated user
|
|
91
158
|
userName: null, // Optional custom username for isolated user
|
|
92
159
|
keepUser: false, // Keep isolated user after command completes (don't delete)
|
|
@@ -175,18 +242,20 @@ function parseOption(args, index, options) {
|
|
|
175
242
|
// --isolated or -i
|
|
176
243
|
if (arg === '--isolated' || arg === '-i') {
|
|
177
244
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
178
|
-
|
|
245
|
+
const value = args[index + 1];
|
|
246
|
+
parseIsolatedValue(value, options);
|
|
179
247
|
return 2;
|
|
180
248
|
} else {
|
|
181
249
|
throw new Error(
|
|
182
|
-
`Option ${arg} requires a backend argument (screen, tmux, docker)`
|
|
250
|
+
`Option ${arg} requires a backend argument (screen, tmux, docker, ssh)`
|
|
183
251
|
);
|
|
184
252
|
}
|
|
185
253
|
}
|
|
186
254
|
|
|
187
255
|
// --isolated=<value>
|
|
188
256
|
if (arg.startsWith('--isolated=')) {
|
|
189
|
-
|
|
257
|
+
const value = arg.split('=')[1];
|
|
258
|
+
parseIsolatedValue(value, options);
|
|
190
259
|
return 1;
|
|
191
260
|
}
|
|
192
261
|
|
|
@@ -218,10 +287,11 @@ function parseOption(args, index, options) {
|
|
|
218
287
|
return 1;
|
|
219
288
|
}
|
|
220
289
|
|
|
221
|
-
// --image (for docker)
|
|
290
|
+
// --image (for docker) - supports sequence for stacked isolation
|
|
222
291
|
if (arg === '--image') {
|
|
223
292
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
224
|
-
|
|
293
|
+
const value = args[index + 1];
|
|
294
|
+
parseImageValue(value, options);
|
|
225
295
|
return 2;
|
|
226
296
|
} else {
|
|
227
297
|
throw new Error(`Option ${arg} requires an image name argument`);
|
|
@@ -230,14 +300,16 @@ function parseOption(args, index, options) {
|
|
|
230
300
|
|
|
231
301
|
// --image=<value>
|
|
232
302
|
if (arg.startsWith('--image=')) {
|
|
233
|
-
|
|
303
|
+
const value = arg.split('=')[1];
|
|
304
|
+
parseImageValue(value, options);
|
|
234
305
|
return 1;
|
|
235
306
|
}
|
|
236
307
|
|
|
237
|
-
// --endpoint (for ssh)
|
|
308
|
+
// --endpoint (for ssh) - supports sequence for stacked isolation
|
|
238
309
|
if (arg === '--endpoint') {
|
|
239
310
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
240
|
-
|
|
311
|
+
const value = args[index + 1];
|
|
312
|
+
parseEndpointValue(value, options);
|
|
241
313
|
return 2;
|
|
242
314
|
} else {
|
|
243
315
|
throw new Error(`Option ${arg} requires an endpoint argument`);
|
|
@@ -246,7 +318,8 @@ function parseOption(args, index, options) {
|
|
|
246
318
|
|
|
247
319
|
// --endpoint=<value>
|
|
248
320
|
if (arg.startsWith('--endpoint=')) {
|
|
249
|
-
|
|
321
|
+
const value = arg.split('=')[1];
|
|
322
|
+
parseEndpointValue(value, options);
|
|
250
323
|
return 1;
|
|
251
324
|
}
|
|
252
325
|
|
|
@@ -375,23 +448,137 @@ function validateOptions(options) {
|
|
|
375
448
|
);
|
|
376
449
|
}
|
|
377
450
|
|
|
378
|
-
// Validate isolation environment
|
|
451
|
+
// Validate isolation environment (with stacking support)
|
|
379
452
|
if (options.isolated !== null) {
|
|
380
|
-
|
|
453
|
+
const stack = options.isolatedStack || [options.isolated];
|
|
454
|
+
const stackDepth = stack.length;
|
|
455
|
+
|
|
456
|
+
// Check depth limit
|
|
457
|
+
if (stackDepth > MAX_ISOLATION_DEPTH) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
`Isolation stack too deep: ${stackDepth} levels (max: ${MAX_ISOLATION_DEPTH})`
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Validate each backend in the stack
|
|
464
|
+
for (const backend of stack) {
|
|
465
|
+
if (backend && !VALID_BACKENDS.includes(backend)) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Invalid isolation environment: "${backend}". Valid options are: ${VALID_BACKENDS.join(', ')}`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Distribute single option values across stack if needed
|
|
473
|
+
if (options.image && !options.imageStack) {
|
|
474
|
+
// Single image value - replicate for all levels
|
|
475
|
+
options.imageStack = Array(stackDepth).fill(options.image);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (options.endpoint && !options.endpointStack) {
|
|
479
|
+
// Single endpoint value - replicate for all levels
|
|
480
|
+
options.endpointStack = Array(stackDepth).fill(options.endpoint);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Validate stack lengths match
|
|
484
|
+
if (options.imageStack && options.imageStack.length !== stackDepth) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`--image has ${options.imageStack.length} value(s) but isolation stack has ${stackDepth} level(s). ` +
|
|
487
|
+
`Use underscores (_) as placeholders for levels that don't need this option.`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (options.endpointStack && options.endpointStack.length !== stackDepth) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`--endpoint has ${options.endpointStack.length} value(s) but isolation stack has ${stackDepth} level(s). ` +
|
|
494
|
+
`Use underscores (_) as placeholders for levels that don't need this option.`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Validate each level has required options
|
|
499
|
+
for (let i = 0; i < stackDepth; i++) {
|
|
500
|
+
const backend = stack[i];
|
|
501
|
+
|
|
502
|
+
// Docker uses --image or defaults to OS-matched image
|
|
503
|
+
if (backend === 'docker') {
|
|
504
|
+
const image = options.imageStack
|
|
505
|
+
? options.imageStack[i]
|
|
506
|
+
: options.image;
|
|
507
|
+
if (!image) {
|
|
508
|
+
// Apply default image
|
|
509
|
+
if (!options.imageStack) {
|
|
510
|
+
options.imageStack = Array(stackDepth).fill(null);
|
|
511
|
+
}
|
|
512
|
+
options.imageStack[i] = getDefaultDockerImage();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// SSH requires --endpoint
|
|
517
|
+
if (backend === 'ssh') {
|
|
518
|
+
const endpoint = options.endpointStack
|
|
519
|
+
? options.endpointStack[i]
|
|
520
|
+
: options.endpoint;
|
|
521
|
+
if (!endpoint) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`SSH isolation at level ${i + 1} requires --endpoint option. ` +
|
|
524
|
+
`Use a sequence like --endpoint "_ user@host _" to specify endpoints for specific levels.`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Set current level values for backward compatibility
|
|
531
|
+
options.image = options.imageStack ? options.imageStack[0] : options.image;
|
|
532
|
+
options.endpoint = options.endpointStack
|
|
533
|
+
? options.endpointStack[0]
|
|
534
|
+
: options.endpoint;
|
|
535
|
+
|
|
536
|
+
// Validate option compatibility with current level (for backward compatible error messages)
|
|
537
|
+
const currentBackend = stack[0];
|
|
538
|
+
|
|
539
|
+
// Image is only valid if stack contains docker
|
|
540
|
+
if (options.image && !stack.includes('docker')) {
|
|
381
541
|
throw new Error(
|
|
382
|
-
|
|
542
|
+
'--image option is only valid when isolation stack includes docker'
|
|
383
543
|
);
|
|
384
544
|
}
|
|
385
545
|
|
|
386
|
-
//
|
|
387
|
-
if (options.
|
|
388
|
-
|
|
546
|
+
// Endpoint is only valid if stack contains ssh
|
|
547
|
+
if (options.endpoint && !stack.includes('ssh')) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
'--endpoint option is only valid when isolation stack includes ssh'
|
|
550
|
+
);
|
|
389
551
|
}
|
|
390
552
|
|
|
391
|
-
//
|
|
392
|
-
if (options.
|
|
553
|
+
// Auto-remove-docker-container is only valid with docker in stack
|
|
554
|
+
if (options.autoRemoveDockerContainer && !stack.includes('docker')) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
'--auto-remove-docker-container option is only valid when isolation stack includes docker'
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// User isolation is not supported with Docker as first level
|
|
561
|
+
if (options.user && currentBackend === 'docker') {
|
|
562
|
+
throw new Error(
|
|
563
|
+
'--isolated-user is not supported with Docker as the first isolation level. ' +
|
|
564
|
+
'Docker uses its own user namespace for isolation.'
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
// Validate options that require isolation when no isolation is specified
|
|
569
|
+
if (options.autoRemoveDockerContainer) {
|
|
393
570
|
throw new Error(
|
|
394
|
-
'
|
|
571
|
+
'--auto-remove-docker-container option is only valid when isolation stack includes docker'
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (options.image) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
'--image option is only valid when isolation stack includes docker'
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
if (options.endpoint) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
'--endpoint option is only valid when isolation stack includes ssh'
|
|
395
582
|
);
|
|
396
583
|
}
|
|
397
584
|
}
|
|
@@ -401,36 +588,13 @@ function validateOptions(options) {
|
|
|
401
588
|
throw new Error('--session option is only valid with --isolated');
|
|
402
589
|
}
|
|
403
590
|
|
|
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
591
|
// Keep-alive is only valid with isolation
|
|
415
592
|
if (options.keepAlive && !options.isolated) {
|
|
416
593
|
throw new Error('--keep-alive option is only valid with --isolated');
|
|
417
594
|
}
|
|
418
595
|
|
|
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
596
|
// User isolation validation
|
|
427
597
|
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
598
|
// Validate custom username if provided
|
|
435
599
|
if (options.userName) {
|
|
436
600
|
if (!/^[a-zA-Z0-9_-]+$/.test(options.userName)) {
|
|
@@ -509,14 +673,25 @@ function getEffectiveMode(options) {
|
|
|
509
673
|
return 'attached';
|
|
510
674
|
}
|
|
511
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Check if isolation stack has multiple levels
|
|
678
|
+
* @param {object} options - Parsed wrapper options
|
|
679
|
+
* @returns {boolean} True if multiple isolation levels
|
|
680
|
+
*/
|
|
681
|
+
function hasStackedIsolation(options) {
|
|
682
|
+
return options.isolatedStack && options.isolatedStack.length > 1;
|
|
683
|
+
}
|
|
684
|
+
|
|
512
685
|
module.exports = {
|
|
513
686
|
parseArgs,
|
|
514
687
|
validateOptions,
|
|
515
688
|
generateSessionName,
|
|
516
689
|
hasIsolation,
|
|
690
|
+
hasStackedIsolation,
|
|
517
691
|
getEffectiveMode,
|
|
518
692
|
isValidUUID,
|
|
519
693
|
generateUUID,
|
|
520
694
|
VALID_BACKENDS,
|
|
521
695
|
VALID_OUTPUT_FORMATS,
|
|
696
|
+
MAX_ISOLATION_DEPTH,
|
|
522
697
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
// Separator and command
|
|
75
|
+
parts.push('--');
|
|
76
|
+
parts.push(command);
|
|
77
|
+
|
|
78
|
+
return parts.join(' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Escape a command for safe execution in a shell context
|
|
83
|
+
* @param {string} cmd - Command to escape
|
|
84
|
+
* @returns {string} Escaped command
|
|
85
|
+
*/
|
|
86
|
+
function escapeForShell(cmd) {
|
|
87
|
+
// For now, simple escaping - could be enhanced
|
|
88
|
+
return cmd.replace(/'/g, "'\\''");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if we're at the last isolation level
|
|
93
|
+
* @param {object} options - Wrapper options
|
|
94
|
+
* @returns {boolean} True if this is the last level
|
|
95
|
+
*/
|
|
96
|
+
function isLastLevel(options) {
|
|
97
|
+
return !options.isolatedStack || options.isolatedStack.length <= 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get current isolation backend from options
|
|
102
|
+
* @param {object} options - Wrapper options
|
|
103
|
+
* @returns {string|null} Current backend or null
|
|
104
|
+
*/
|
|
105
|
+
function getCurrentBackend(options) {
|
|
106
|
+
if (options.isolatedStack && options.isolatedStack.length > 0) {
|
|
107
|
+
return options.isolatedStack[0];
|
|
108
|
+
}
|
|
109
|
+
return options.isolated;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get option value for current level
|
|
114
|
+
* @param {(string|null)[]} stack - Option value stack
|
|
115
|
+
* @returns {string|null} Value for current level
|
|
116
|
+
*/
|
|
117
|
+
function getCurrentValue(stack) {
|
|
118
|
+
if (Array.isArray(stack) && stack.length > 0) {
|
|
119
|
+
return stack[0];
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
buildNextLevelCommand,
|
|
126
|
+
escapeForShell,
|
|
127
|
+
isLastLevel,
|
|
128
|
+
getCurrentBackend,
|
|
129
|
+
getCurrentValue,
|
|
130
|
+
};
|
package/src/lib/isolation.js
CHANGED
|
@@ -775,21 +775,48 @@ function runInDocker(command, options = {}) {
|
|
|
775
775
|
|
|
776
776
|
/**
|
|
777
777
|
* Run command in the specified isolation environment
|
|
778
|
+
* Supports stacked isolation where each level calls $ with remaining levels
|
|
778
779
|
* @param {string} backend - Isolation environment (screen, tmux, docker, ssh)
|
|
779
780
|
* @param {string} command - Command to execute
|
|
780
781
|
* @param {object} options - Options
|
|
781
782
|
* @returns {Promise<{success: boolean, message: string}>}
|
|
782
783
|
*/
|
|
783
784
|
function runIsolated(backend, command, options = {}) {
|
|
785
|
+
// If stacked isolation, build the command for next level
|
|
786
|
+
let effectiveCommand = command;
|
|
787
|
+
|
|
788
|
+
if (options.isolatedStack && options.isolatedStack.length > 1) {
|
|
789
|
+
// Lazy load to avoid circular dependency
|
|
790
|
+
const { buildNextLevelCommand } = require('./command-builder');
|
|
791
|
+
effectiveCommand = buildNextLevelCommand(options, command);
|
|
792
|
+
|
|
793
|
+
if (DEBUG) {
|
|
794
|
+
console.log(
|
|
795
|
+
`[DEBUG] Stacked isolation - level command: ${effectiveCommand}`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Get current level option values
|
|
801
|
+
const currentOptions = {
|
|
802
|
+
...options,
|
|
803
|
+
// Use current level values from stacks
|
|
804
|
+
image: options.imageStack ? options.imageStack[0] : options.image,
|
|
805
|
+
endpoint: options.endpointStack
|
|
806
|
+
? options.endpointStack[0]
|
|
807
|
+
: options.endpoint,
|
|
808
|
+
session: options.sessionStack ? options.sessionStack[0] : options.session,
|
|
809
|
+
};
|
|
810
|
+
|
|
784
811
|
switch (backend) {
|
|
785
812
|
case 'screen':
|
|
786
|
-
return runInScreen(
|
|
813
|
+
return runInScreen(effectiveCommand, currentOptions);
|
|
787
814
|
case 'tmux':
|
|
788
|
-
return runInTmux(
|
|
815
|
+
return runInTmux(effectiveCommand, currentOptions);
|
|
789
816
|
case 'docker':
|
|
790
|
-
return runInDocker(
|
|
817
|
+
return runInDocker(effectiveCommand, currentOptions);
|
|
791
818
|
case 'ssh':
|
|
792
|
-
return runInSsh(
|
|
819
|
+
return runInSsh(effectiveCommand, currentOptions);
|
|
793
820
|
default:
|
|
794
821
|
return Promise.resolve({
|
|
795
822
|
success: false,
|