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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.20.4",
3
+ "version": "0.21.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -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
- endpoint: null, // SSH endpoint (e.g., user@host)
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
- options.isolated = args[index + 1].toLowerCase();
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
- options.isolated = arg.split('=')[1].toLowerCase();
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
- options.image = args[index + 1];
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
- options.image = arg.split('=')[1];
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
- options.endpoint = args[index + 1];
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
- options.endpoint = arg.split('=')[1];
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
- if (!VALID_BACKENDS.includes(options.isolated)) {
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
- `Invalid isolation environment: "${options.isolated}". Valid options are: ${VALID_BACKENDS.join(', ')}`
542
+ '--image option is only valid when isolation stack includes docker'
383
543
  );
384
544
  }
385
545
 
386
- // Docker uses --image or defaults to OS-matched image
387
- if (options.isolated === 'docker' && !options.image) {
388
- options.image = getDefaultDockerImage();
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
- // SSH requires --endpoint
392
- if (options.isolated === 'ssh' && !options.endpoint) {
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
- 'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)'
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
+ };
@@ -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(command, options);
813
+ return runInScreen(effectiveCommand, currentOptions);
787
814
  case 'tmux':
788
- return runInTmux(command, options);
815
+ return runInTmux(effectiveCommand, currentOptions);
789
816
  case 'docker':
790
- return runInDocker(command, options);
817
+ return runInDocker(effectiveCommand, currentOptions);
791
818
  case 'ssh':
792
- return runInSsh(command, options);
819
+ return runInSsh(effectiveCommand, currentOptions);
793
820
  default:
794
821
  return Promise.resolve({
795
822
  success: false,