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.
@@ -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
+ });
@@ -232,7 +232,7 @@ describe('parseArgs', () => {
232
232
  'npm',
233
233
  'test',
234
234
  ]);
235
- }, /--image option is only valid with --isolated docker/);
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 with --isolated ssh/);
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 with --isolated docker/);
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 with --isolated docker/);
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