start-command 0.3.1

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.
Files changed (38) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.changeset/isolation-support.md +30 -0
  4. package/.github/workflows/release.yml +292 -0
  5. package/.husky/pre-commit +1 -0
  6. package/.prettierignore +6 -0
  7. package/.prettierrc +10 -0
  8. package/CHANGELOG.md +24 -0
  9. package/LICENSE +24 -0
  10. package/README.md +249 -0
  11. package/REQUIREMENTS.md +229 -0
  12. package/bun.lock +453 -0
  13. package/bunfig.toml +3 -0
  14. package/eslint.config.mjs +122 -0
  15. package/experiments/debug-regex.js +49 -0
  16. package/experiments/isolation-design.md +142 -0
  17. package/experiments/test-cli.sh +42 -0
  18. package/experiments/test-substitution.js +143 -0
  19. package/package.json +63 -0
  20. package/scripts/changeset-version.mjs +38 -0
  21. package/scripts/check-file-size.mjs +103 -0
  22. package/scripts/create-github-release.mjs +93 -0
  23. package/scripts/create-manual-changeset.mjs +89 -0
  24. package/scripts/format-github-release.mjs +83 -0
  25. package/scripts/format-release-notes.mjs +219 -0
  26. package/scripts/instant-version-bump.mjs +121 -0
  27. package/scripts/publish-to-npm.mjs +129 -0
  28. package/scripts/setup-npm.mjs +37 -0
  29. package/scripts/validate-changeset.mjs +107 -0
  30. package/scripts/version-and-commit.mjs +237 -0
  31. package/src/bin/cli.js +670 -0
  32. package/src/lib/args-parser.js +259 -0
  33. package/src/lib/isolation.js +419 -0
  34. package/src/lib/substitution.js +323 -0
  35. package/src/lib/substitutions.lino +308 -0
  36. package/test/args-parser.test.js +389 -0
  37. package/test/isolation.test.js +248 -0
  38. package/test/substitution.test.js +236 -0
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit tests for the argument parser
4
+ * Tests wrapper options parsing, validation, and command extraction
5
+ */
6
+
7
+ const { describe, it } = require('node:test');
8
+ const assert = require('assert');
9
+ const {
10
+ parseArgs,
11
+ validateOptions,
12
+ generateSessionName,
13
+ hasIsolation,
14
+ getEffectiveMode,
15
+ VALID_BACKENDS,
16
+ } = require('../src/lib/args-parser');
17
+
18
+ describe('parseArgs', () => {
19
+ describe('basic command parsing', () => {
20
+ it('should parse simple command without options', () => {
21
+ const result = parseArgs(['echo', 'hello', 'world']);
22
+ assert.strictEqual(result.command, 'echo hello world');
23
+ assert.strictEqual(result.wrapperOptions.isolated, null);
24
+ assert.strictEqual(result.wrapperOptions.attached, false);
25
+ assert.strictEqual(result.wrapperOptions.detached, false);
26
+ });
27
+
28
+ it('should parse command with -- separator', () => {
29
+ const result = parseArgs(['--', 'npm', 'test']);
30
+ assert.strictEqual(result.command, 'npm test');
31
+ });
32
+
33
+ it('should parse empty command correctly', () => {
34
+ const result = parseArgs([]);
35
+ assert.strictEqual(result.command, '');
36
+ });
37
+ });
38
+
39
+ describe('isolation options', () => {
40
+ it('should parse --isolated with value', () => {
41
+ const result = parseArgs(['--isolated', 'tmux', '--', 'npm', 'test']);
42
+ assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
43
+ assert.strictEqual(result.command, 'npm test');
44
+ });
45
+
46
+ it('should parse -i shorthand', () => {
47
+ const result = parseArgs(['-i', 'screen', '--', 'npm', 'start']);
48
+ assert.strictEqual(result.wrapperOptions.isolated, 'screen');
49
+ assert.strictEqual(result.command, 'npm start');
50
+ });
51
+
52
+ it('should parse --isolated=value format', () => {
53
+ const result = parseArgs(['--isolated=tmux', '--', 'ls', '-la']);
54
+ assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
55
+ });
56
+
57
+ it('should normalize backend to lowercase', () => {
58
+ const result = parseArgs(['--isolated', 'TMUX', '--', 'echo', 'test']);
59
+ assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
60
+ });
61
+
62
+ it('should throw error for missing backend argument', () => {
63
+ assert.throws(() => {
64
+ parseArgs(['--isolated']);
65
+ }, /requires a backend argument/);
66
+ });
67
+ });
68
+
69
+ describe('attached and detached modes', () => {
70
+ it('should parse --attached flag', () => {
71
+ const result = parseArgs([
72
+ '--isolated',
73
+ 'tmux',
74
+ '--attached',
75
+ '--',
76
+ 'npm',
77
+ 'test',
78
+ ]);
79
+ assert.strictEqual(result.wrapperOptions.attached, true);
80
+ assert.strictEqual(result.wrapperOptions.detached, false);
81
+ });
82
+
83
+ it('should parse -a shorthand', () => {
84
+ const result = parseArgs(['-i', 'screen', '-a', '--', 'npm', 'start']);
85
+ assert.strictEqual(result.wrapperOptions.attached, true);
86
+ });
87
+
88
+ it('should parse --detached flag', () => {
89
+ const result = parseArgs([
90
+ '--isolated',
91
+ 'tmux',
92
+ '--detached',
93
+ '--',
94
+ 'npm',
95
+ 'start',
96
+ ]);
97
+ assert.strictEqual(result.wrapperOptions.detached, true);
98
+ assert.strictEqual(result.wrapperOptions.attached, false);
99
+ });
100
+
101
+ it('should parse -d shorthand', () => {
102
+ const result = parseArgs(['-i', 'screen', '-d', '--', 'npm', 'start']);
103
+ assert.strictEqual(result.wrapperOptions.detached, true);
104
+ });
105
+
106
+ it('should throw error when both --attached and --detached are set', () => {
107
+ assert.throws(() => {
108
+ parseArgs([
109
+ '--isolated',
110
+ 'tmux',
111
+ '--attached',
112
+ '--detached',
113
+ '--',
114
+ 'npm',
115
+ 'test',
116
+ ]);
117
+ }, /Cannot use both --attached and --detached/);
118
+ });
119
+
120
+ it('should provide helpful error message for mode conflict', () => {
121
+ try {
122
+ parseArgs(['-i', 'screen', '-a', '-d', '--', 'npm', 'test']);
123
+ assert.fail('Should have thrown an error');
124
+ } catch (err) {
125
+ assert.ok(err.message.includes('Please choose only one mode'));
126
+ }
127
+ });
128
+ });
129
+
130
+ describe('session option', () => {
131
+ it('should parse --session with value', () => {
132
+ const result = parseArgs([
133
+ '--isolated',
134
+ 'tmux',
135
+ '--session',
136
+ 'my-session',
137
+ '--',
138
+ 'npm',
139
+ 'test',
140
+ ]);
141
+ assert.strictEqual(result.wrapperOptions.session, 'my-session');
142
+ });
143
+
144
+ it('should parse -s shorthand', () => {
145
+ const result = parseArgs([
146
+ '-i',
147
+ 'screen',
148
+ '-s',
149
+ 'test-session',
150
+ '--',
151
+ 'npm',
152
+ 'start',
153
+ ]);
154
+ assert.strictEqual(result.wrapperOptions.session, 'test-session');
155
+ });
156
+
157
+ it('should parse --session=value format', () => {
158
+ const result = parseArgs([
159
+ '--isolated',
160
+ 'tmux',
161
+ '--session=my-session',
162
+ '--',
163
+ 'npm',
164
+ 'test',
165
+ ]);
166
+ assert.strictEqual(result.wrapperOptions.session, 'my-session');
167
+ });
168
+
169
+ it('should throw error for session without isolation', () => {
170
+ assert.throws(() => {
171
+ parseArgs(['--session', 'my-session', '--', 'npm', 'test']);
172
+ }, /--session option is only valid with --isolated/);
173
+ });
174
+ });
175
+
176
+ describe('docker image option', () => {
177
+ it('should parse --image with value', () => {
178
+ const result = parseArgs([
179
+ '--isolated',
180
+ 'docker',
181
+ '--image',
182
+ 'node:20',
183
+ '--',
184
+ 'npm',
185
+ 'test',
186
+ ]);
187
+ assert.strictEqual(result.wrapperOptions.image, 'node:20');
188
+ });
189
+
190
+ it('should parse --image=value format', () => {
191
+ const result = parseArgs([
192
+ '--isolated',
193
+ 'docker',
194
+ '--image=alpine:latest',
195
+ '--',
196
+ 'ls',
197
+ ]);
198
+ assert.strictEqual(result.wrapperOptions.image, 'alpine:latest');
199
+ });
200
+
201
+ it('should throw error for docker without image', () => {
202
+ assert.throws(() => {
203
+ parseArgs(['--isolated', 'docker', '--', 'npm', 'test']);
204
+ }, /Docker isolation requires --image option/);
205
+ });
206
+
207
+ it('should throw error for image with non-docker backend', () => {
208
+ assert.throws(() => {
209
+ parseArgs([
210
+ '--isolated',
211
+ 'tmux',
212
+ '--image',
213
+ 'node:20',
214
+ '--',
215
+ 'npm',
216
+ 'test',
217
+ ]);
218
+ }, /--image option is only valid with --isolated docker/);
219
+ });
220
+ });
221
+
222
+ describe('command without separator', () => {
223
+ it('should parse command after options without separator', () => {
224
+ const result = parseArgs(['-i', 'tmux', '-d', 'npm', 'start']);
225
+ assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
226
+ assert.strictEqual(result.wrapperOptions.detached, true);
227
+ assert.strictEqual(result.command, 'npm start');
228
+ });
229
+
230
+ it('should handle mixed options and command', () => {
231
+ const result = parseArgs(['-i', 'screen', 'echo', 'hello']);
232
+ assert.strictEqual(result.wrapperOptions.isolated, 'screen');
233
+ assert.strictEqual(result.command, 'echo hello');
234
+ });
235
+ });
236
+
237
+ describe('backend validation', () => {
238
+ it('should accept valid backends', () => {
239
+ for (const backend of VALID_BACKENDS) {
240
+ // Docker requires image, so handle it separately
241
+ if (backend === 'docker') {
242
+ const result = parseArgs([
243
+ '-i',
244
+ backend,
245
+ '--image',
246
+ 'alpine',
247
+ '--',
248
+ 'echo',
249
+ 'test',
250
+ ]);
251
+ assert.strictEqual(result.wrapperOptions.isolated, backend);
252
+ } else {
253
+ const result = parseArgs(['-i', backend, '--', 'echo', 'test']);
254
+ assert.strictEqual(result.wrapperOptions.isolated, backend);
255
+ }
256
+ }
257
+ });
258
+
259
+ it('should throw error for invalid backend', () => {
260
+ assert.throws(() => {
261
+ parseArgs(['--isolated', 'invalid-backend', '--', 'npm', 'test']);
262
+ }, /Invalid isolation backend/);
263
+ });
264
+
265
+ it('should list valid backends in error message', () => {
266
+ try {
267
+ parseArgs(['--isolated', 'invalid', '--', 'npm', 'test']);
268
+ assert.fail('Should have thrown an error');
269
+ } catch (err) {
270
+ for (const backend of VALID_BACKENDS) {
271
+ assert.ok(
272
+ err.message.includes(backend),
273
+ `Error should mention ${backend}`
274
+ );
275
+ }
276
+ }
277
+ });
278
+ });
279
+ });
280
+
281
+ describe('validateOptions', () => {
282
+ it('should pass for valid options', () => {
283
+ assert.doesNotThrow(() => {
284
+ validateOptions({
285
+ isolated: 'tmux',
286
+ attached: false,
287
+ detached: true,
288
+ session: 'test',
289
+ image: null,
290
+ });
291
+ });
292
+ });
293
+
294
+ it('should throw for attached and detached together', () => {
295
+ assert.throws(() => {
296
+ validateOptions({
297
+ isolated: 'screen',
298
+ attached: true,
299
+ detached: true,
300
+ session: null,
301
+ image: null,
302
+ });
303
+ }, /Cannot use both --attached and --detached/);
304
+ });
305
+
306
+ it('should pass for docker with image', () => {
307
+ assert.doesNotThrow(() => {
308
+ validateOptions({
309
+ isolated: 'docker',
310
+ attached: false,
311
+ detached: false,
312
+ session: null,
313
+ image: 'node:20',
314
+ });
315
+ });
316
+ });
317
+ });
318
+
319
+ describe('generateSessionName', () => {
320
+ it('should generate unique session names', () => {
321
+ const name1 = generateSessionName();
322
+ const name2 = generateSessionName();
323
+ assert.notStrictEqual(name1, name2);
324
+ });
325
+
326
+ it('should use default prefix', () => {
327
+ const name = generateSessionName();
328
+ assert.ok(name.startsWith('start-'));
329
+ });
330
+
331
+ it('should use custom prefix', () => {
332
+ const name = generateSessionName('custom');
333
+ assert.ok(name.startsWith('custom-'));
334
+ });
335
+
336
+ it('should contain timestamp-like portion', () => {
337
+ const name = generateSessionName();
338
+ // Should have format: prefix-timestamp-random
339
+ const parts = name.split('-');
340
+ assert.ok(parts.length >= 3);
341
+ // Second part should be numeric (timestamp)
342
+ assert.ok(/^\d+$/.test(parts[1]));
343
+ });
344
+ });
345
+
346
+ describe('hasIsolation', () => {
347
+ it('should return true when isolated is set', () => {
348
+ assert.strictEqual(hasIsolation({ isolated: 'tmux' }), true);
349
+ });
350
+
351
+ it('should return false when isolated is null', () => {
352
+ assert.strictEqual(hasIsolation({ isolated: null }), false);
353
+ });
354
+ });
355
+
356
+ describe('getEffectiveMode', () => {
357
+ it('should return attached by default', () => {
358
+ const mode = getEffectiveMode({ attached: false, detached: false });
359
+ assert.strictEqual(mode, 'attached');
360
+ });
361
+
362
+ it('should return attached when explicitly set', () => {
363
+ const mode = getEffectiveMode({ attached: true, detached: false });
364
+ assert.strictEqual(mode, 'attached');
365
+ });
366
+
367
+ it('should return detached when set', () => {
368
+ const mode = getEffectiveMode({ attached: false, detached: true });
369
+ assert.strictEqual(mode, 'detached');
370
+ });
371
+ });
372
+
373
+ describe('VALID_BACKENDS', () => {
374
+ it('should include screen', () => {
375
+ assert.ok(VALID_BACKENDS.includes('screen'));
376
+ });
377
+
378
+ it('should include tmux', () => {
379
+ assert.ok(VALID_BACKENDS.includes('tmux'));
380
+ });
381
+
382
+ it('should include docker', () => {
383
+ assert.ok(VALID_BACKENDS.includes('docker'));
384
+ });
385
+
386
+ it('should include zellij', () => {
387
+ assert.ok(VALID_BACKENDS.includes('zellij'));
388
+ });
389
+ });
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit tests for the isolation module
4
+ * Tests command availability checking and session name generation
5
+ * Note: Actual isolation execution tests are integration tests that require the tools to be installed
6
+ */
7
+
8
+ const { describe, it } = require('node:test');
9
+ const assert = require('assert');
10
+ const { isCommandAvailable } = require('../src/lib/isolation');
11
+
12
+ describe('Isolation Module', () => {
13
+ describe('isCommandAvailable', () => {
14
+ it('should return true for common commands (echo)', () => {
15
+ // echo is available on all platforms
16
+ const result = isCommandAvailable('echo');
17
+ assert.strictEqual(result, true);
18
+ });
19
+
20
+ it('should return true for node', () => {
21
+ // node should be available since we are running tests with it
22
+ const result = isCommandAvailable('node');
23
+ assert.strictEqual(result, true);
24
+ });
25
+
26
+ it('should return false for non-existent command', () => {
27
+ const result = isCommandAvailable('nonexistent-command-12345');
28
+ assert.strictEqual(result, false);
29
+ });
30
+
31
+ it('should return false for empty command', () => {
32
+ const result = isCommandAvailable('');
33
+ assert.strictEqual(result, false);
34
+ });
35
+ });
36
+
37
+ describe('isolation backend checks', () => {
38
+ // These tests check if specific backends are available
39
+ // They don't fail if not installed, just report status
40
+
41
+ it('should check if screen is available', () => {
42
+ const result = isCommandAvailable('screen');
43
+ console.log(` screen available: ${result}`);
44
+ assert.ok(typeof result === 'boolean');
45
+ });
46
+
47
+ it('should check if tmux is available', () => {
48
+ const result = isCommandAvailable('tmux');
49
+ console.log(` tmux available: ${result}`);
50
+ assert.ok(typeof result === 'boolean');
51
+ });
52
+
53
+ it('should check if docker is available', () => {
54
+ const result = isCommandAvailable('docker');
55
+ console.log(` docker available: ${result}`);
56
+ assert.ok(typeof result === 'boolean');
57
+ });
58
+
59
+ it('should check if zellij is available', () => {
60
+ const result = isCommandAvailable('zellij');
61
+ console.log(` zellij available: ${result}`);
62
+ assert.ok(typeof result === 'boolean');
63
+ });
64
+ });
65
+ });
66
+
67
+ describe('Isolation Runner Error Handling', () => {
68
+ // These tests verify error messages when backends are not available
69
+
70
+ const {
71
+ runInScreen,
72
+ runInTmux,
73
+ runInDocker,
74
+ runInZellij,
75
+ } = require('../src/lib/isolation');
76
+
77
+ describe('runInScreen', () => {
78
+ it('should return informative error if screen is not installed', async () => {
79
+ // Skip if screen is installed
80
+ if (isCommandAvailable('screen')) {
81
+ console.log(' Skipping: screen is installed');
82
+ return;
83
+ }
84
+
85
+ const result = await runInScreen('echo test', { detached: true });
86
+ assert.strictEqual(result.success, false);
87
+ assert.ok(result.message.includes('screen is not installed'));
88
+ assert.ok(
89
+ result.message.includes('apt-get') || result.message.includes('brew')
90
+ );
91
+ });
92
+ });
93
+
94
+ describe('runInTmux', () => {
95
+ it('should return informative error if tmux is not installed', async () => {
96
+ // Skip if tmux is installed
97
+ if (isCommandAvailable('tmux')) {
98
+ console.log(' Skipping: tmux is installed');
99
+ return;
100
+ }
101
+
102
+ const result = await runInTmux('echo test', { detached: true });
103
+ assert.strictEqual(result.success, false);
104
+ assert.ok(result.message.includes('tmux is not installed'));
105
+ assert.ok(
106
+ result.message.includes('apt-get') || result.message.includes('brew')
107
+ );
108
+ });
109
+ });
110
+
111
+ describe('runInDocker', () => {
112
+ it('should return informative error if docker is not installed', async () => {
113
+ // Skip if docker is installed
114
+ if (isCommandAvailable('docker')) {
115
+ console.log(' Skipping: docker is installed');
116
+ return;
117
+ }
118
+
119
+ const result = await runInDocker('echo test', {
120
+ image: 'alpine',
121
+ detached: true,
122
+ });
123
+ assert.strictEqual(result.success, false);
124
+ assert.ok(result.message.includes('docker is not installed'));
125
+ });
126
+
127
+ it('should require image option', async () => {
128
+ // Skip if docker is not installed - the error will be about docker not being installed
129
+ if (!isCommandAvailable('docker')) {
130
+ console.log(' Skipping: docker not installed');
131
+ return;
132
+ }
133
+
134
+ const result = await runInDocker('echo test', { detached: true });
135
+ assert.strictEqual(result.success, false);
136
+ // Message should mention image requirement
137
+ assert.ok(
138
+ result.message.includes('image') ||
139
+ result.message.includes('--image') ||
140
+ result.message.includes('Docker isolation requires')
141
+ );
142
+ });
143
+ });
144
+
145
+ describe('runInZellij', () => {
146
+ it('should return informative error if zellij is not installed', async () => {
147
+ // Skip if zellij is installed
148
+ if (isCommandAvailable('zellij')) {
149
+ console.log(' Skipping: zellij is installed');
150
+ return;
151
+ }
152
+
153
+ const result = await runInZellij('echo test', { detached: true });
154
+ assert.strictEqual(result.success, false);
155
+ assert.ok(result.message.includes('zellij is not installed'));
156
+ assert.ok(
157
+ result.message.includes('cargo') || result.message.includes('brew')
158
+ );
159
+ });
160
+ });
161
+ });
162
+
163
+ describe('Isolation Runner with Available Backends', () => {
164
+ // Integration-style tests that run if backends are available
165
+ // These test actual execution in detached mode (quick and non-blocking)
166
+
167
+ const {
168
+ runInScreen,
169
+ runInTmux,
170
+ runIsolated,
171
+ } = require('../src/lib/isolation');
172
+ const { execSync } = require('child_process');
173
+
174
+ describe('runInScreen (if available)', () => {
175
+ it('should run command in detached screen session', async () => {
176
+ if (!isCommandAvailable('screen')) {
177
+ console.log(' Skipping: screen not installed');
178
+ return;
179
+ }
180
+
181
+ const result = await runInScreen('echo "test from screen"', {
182
+ session: `test-session-${Date.now()}`,
183
+ detached: true,
184
+ });
185
+
186
+ assert.strictEqual(result.success, true);
187
+ assert.ok(result.sessionName);
188
+ assert.ok(result.message.includes('screen'));
189
+ assert.ok(result.message.includes('Reattach with'));
190
+
191
+ // Clean up the session
192
+ try {
193
+ execSync(`screen -S ${result.sessionName} -X quit`, {
194
+ stdio: 'ignore',
195
+ });
196
+ } catch {
197
+ // Session may have already exited
198
+ }
199
+ });
200
+ });
201
+
202
+ describe('runInTmux (if available)', () => {
203
+ it('should run command in detached tmux session', async () => {
204
+ if (!isCommandAvailable('tmux')) {
205
+ console.log(' Skipping: tmux not installed');
206
+ return;
207
+ }
208
+
209
+ const result = await runInTmux('echo "test from tmux"', {
210
+ session: `test-session-${Date.now()}`,
211
+ detached: true,
212
+ });
213
+
214
+ assert.strictEqual(result.success, true);
215
+ assert.ok(result.sessionName);
216
+ assert.ok(result.message.includes('tmux'));
217
+ assert.ok(result.message.includes('Reattach with'));
218
+
219
+ // Clean up the session
220
+ try {
221
+ execSync(`tmux kill-session -t ${result.sessionName}`, {
222
+ stdio: 'ignore',
223
+ });
224
+ } catch {
225
+ // Session may have already exited
226
+ }
227
+ });
228
+ });
229
+
230
+ describe('runIsolated dispatcher', () => {
231
+ it('should dispatch to correct backend', async () => {
232
+ // Test with a backend that returns predictable error for missing tools
233
+ const result = await runIsolated('nonexistent-backend', 'echo test', {});
234
+ assert.strictEqual(result.success, false);
235
+ assert.ok(result.message.includes('Unknown isolation backend'));
236
+ });
237
+
238
+ it('should pass options to backend', async () => {
239
+ // Test docker without image - should fail with specific error
240
+ const result = await runIsolated('docker', 'echo test', {});
241
+ assert.strictEqual(result.success, false);
242
+ // Either docker not installed or image required
243
+ assert.ok(
244
+ result.message.includes('docker') || result.message.includes('image')
245
+ );
246
+ });
247
+ });
248
+ });