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,259 @@
1
+ /**
2
+ * Argument Parser for start-command wrapper options
3
+ *
4
+ * Supports two syntax patterns:
5
+ * 1. $ [wrapper-options] -- [command-options]
6
+ * 2. $ [wrapper-options] command [command-options]
7
+ *
8
+ * Wrapper Options:
9
+ * --isolated, -i <backend> Run in isolated environment (screen, tmux, docker, zellij)
10
+ * --attached, -a Run in attached mode (foreground)
11
+ * --detached, -d Run in detached mode (background)
12
+ * --session, -s <name> Session name for isolation
13
+ * --image <image> Docker image (required for docker isolation)
14
+ */
15
+
16
+ // Debug mode from environment
17
+ const DEBUG =
18
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
19
+
20
+ /**
21
+ * Valid isolation backends
22
+ */
23
+ const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'zellij'];
24
+
25
+ /**
26
+ * Parse command line arguments into wrapper options and command
27
+ * @param {string[]} args - Array of command line arguments
28
+ * @returns {{wrapperOptions: object, command: string, rawCommand: string[]}}
29
+ */
30
+ function parseArgs(args) {
31
+ const wrapperOptions = {
32
+ isolated: null, // Isolation backend: screen, tmux, docker, zellij
33
+ attached: false, // Run in attached mode
34
+ detached: false, // Run in detached mode
35
+ session: null, // Session name
36
+ image: null, // Docker image
37
+ };
38
+
39
+ let commandArgs = [];
40
+ let i = 0;
41
+
42
+ // Find the separator '--' or detect where command starts
43
+ const separatorIndex = args.indexOf('--');
44
+
45
+ if (separatorIndex !== -1) {
46
+ // Pattern 1: explicit separator
47
+ const wrapperArgs = args.slice(0, separatorIndex);
48
+ commandArgs = args.slice(separatorIndex + 1);
49
+
50
+ parseWrapperArgs(wrapperArgs, wrapperOptions);
51
+ } else {
52
+ // Pattern 2: parse until we hit a non-option argument
53
+ while (i < args.length) {
54
+ const arg = args[i];
55
+
56
+ if (arg.startsWith('-')) {
57
+ const consumed = parseOption(args, i, wrapperOptions);
58
+ if (consumed === 0) {
59
+ // Unknown option, treat rest as command
60
+ commandArgs = args.slice(i);
61
+ break;
62
+ }
63
+ i += consumed;
64
+ } else {
65
+ // Non-option argument, rest is command
66
+ commandArgs = args.slice(i);
67
+ break;
68
+ }
69
+ }
70
+ }
71
+
72
+ // Validate options
73
+ validateOptions(wrapperOptions);
74
+
75
+ return {
76
+ wrapperOptions,
77
+ command: commandArgs.join(' '),
78
+ rawCommand: commandArgs,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Parse wrapper arguments
84
+ * @param {string[]} args - Wrapper arguments
85
+ * @param {object} options - Options object to populate
86
+ */
87
+ function parseWrapperArgs(args, options) {
88
+ let i = 0;
89
+ while (i < args.length) {
90
+ const consumed = parseOption(args, i, options);
91
+ if (consumed === 0) {
92
+ if (DEBUG) {
93
+ console.warn(`Unknown wrapper option: ${args[i]}`);
94
+ }
95
+ i++;
96
+ } else {
97
+ i += consumed;
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Parse a single option from args array
104
+ * @param {string[]} args - Arguments array
105
+ * @param {number} index - Current index
106
+ * @param {object} options - Options object to populate
107
+ * @returns {number} Number of arguments consumed (0 if not recognized)
108
+ */
109
+ function parseOption(args, index, options) {
110
+ const arg = args[index];
111
+
112
+ // --isolated or -i
113
+ if (arg === '--isolated' || arg === '-i') {
114
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
115
+ options.isolated = args[index + 1].toLowerCase();
116
+ return 2;
117
+ } else {
118
+ throw new Error(
119
+ `Option ${arg} requires a backend argument (screen, tmux, docker, zellij)`
120
+ );
121
+ }
122
+ }
123
+
124
+ // --isolated=<value>
125
+ if (arg.startsWith('--isolated=')) {
126
+ options.isolated = arg.split('=')[1].toLowerCase();
127
+ return 1;
128
+ }
129
+
130
+ // --attached or -a
131
+ if (arg === '--attached' || arg === '-a') {
132
+ options.attached = true;
133
+ return 1;
134
+ }
135
+
136
+ // --detached or -d
137
+ if (arg === '--detached' || arg === '-d') {
138
+ options.detached = true;
139
+ return 1;
140
+ }
141
+
142
+ // --session or -s
143
+ if (arg === '--session' || arg === '-s') {
144
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
145
+ options.session = args[index + 1];
146
+ return 2;
147
+ } else {
148
+ throw new Error(`Option ${arg} requires a session name argument`);
149
+ }
150
+ }
151
+
152
+ // --session=<value>
153
+ if (arg.startsWith('--session=')) {
154
+ options.session = arg.split('=')[1];
155
+ return 1;
156
+ }
157
+
158
+ // --image (for docker)
159
+ if (arg === '--image') {
160
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
161
+ options.image = args[index + 1];
162
+ return 2;
163
+ } else {
164
+ throw new Error(`Option ${arg} requires an image name argument`);
165
+ }
166
+ }
167
+
168
+ // --image=<value>
169
+ if (arg.startsWith('--image=')) {
170
+ options.image = arg.split('=')[1];
171
+ return 1;
172
+ }
173
+
174
+ // Not a recognized wrapper option
175
+ return 0;
176
+ }
177
+
178
+ /**
179
+ * Validate parsed options
180
+ * @param {object} options - Parsed options
181
+ * @throws {Error} If options are invalid
182
+ */
183
+ function validateOptions(options) {
184
+ // Check attached and detached conflict
185
+ if (options.attached && options.detached) {
186
+ throw new Error(
187
+ 'Cannot use both --attached and --detached at the same time. Please choose only one mode.'
188
+ );
189
+ }
190
+
191
+ // Validate isolation backend
192
+ if (options.isolated !== null) {
193
+ if (!VALID_BACKENDS.includes(options.isolated)) {
194
+ throw new Error(
195
+ `Invalid isolation backend: "${options.isolated}". Valid options are: ${VALID_BACKENDS.join(', ')}`
196
+ );
197
+ }
198
+
199
+ // Docker requires --image
200
+ if (options.isolated === 'docker' && !options.image) {
201
+ throw new Error(
202
+ 'Docker isolation requires --image option to specify the container image'
203
+ );
204
+ }
205
+ }
206
+
207
+ // Session name is only valid with isolation
208
+ if (options.session && !options.isolated) {
209
+ throw new Error('--session option is only valid with --isolated');
210
+ }
211
+
212
+ // Image is only valid with docker
213
+ if (options.image && options.isolated !== 'docker') {
214
+ throw new Error('--image option is only valid with --isolated docker');
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Generate a unique session name
220
+ * @param {string} [prefix='start'] - Prefix for the session name
221
+ * @returns {string} Generated session name
222
+ */
223
+ function generateSessionName(prefix = 'start') {
224
+ const timestamp = Date.now();
225
+ const random = Math.random().toString(36).substring(2, 8);
226
+ return `${prefix}-${timestamp}-${random}`;
227
+ }
228
+
229
+ /**
230
+ * Check if any isolation options are present
231
+ * @param {object} options - Parsed wrapper options
232
+ * @returns {boolean} True if isolation is requested
233
+ */
234
+ function hasIsolation(options) {
235
+ return options.isolated !== null;
236
+ }
237
+
238
+ /**
239
+ * Get the effective mode for isolation
240
+ * Multiplexers default to attached, docker defaults to attached
241
+ * @param {object} options - Parsed wrapper options
242
+ * @returns {'attached'|'detached'} The effective mode
243
+ */
244
+ function getEffectiveMode(options) {
245
+ if (options.detached) {
246
+ return 'detached';
247
+ }
248
+ // Default to attached for all backends
249
+ return 'attached';
250
+ }
251
+
252
+ module.exports = {
253
+ parseArgs,
254
+ validateOptions,
255
+ generateSessionName,
256
+ hasIsolation,
257
+ getEffectiveMode,
258
+ VALID_BACKENDS,
259
+ };
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Isolation Runners for start-command
3
+ *
4
+ * Provides execution of commands in various isolated environments:
5
+ * - screen: GNU Screen terminal multiplexer
6
+ * - tmux: tmux terminal multiplexer
7
+ * - zellij: Modern terminal workspace
8
+ * - docker: Docker containers
9
+ */
10
+
11
+ const { execSync, spawn } = require('child_process');
12
+ const { generateSessionName } = require('./args-parser');
13
+
14
+ // Debug mode from environment
15
+ const DEBUG =
16
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
17
+
18
+ /**
19
+ * Check if a command is available on the system
20
+ * @param {string} command - Command to check
21
+ * @returns {boolean} True if command is available
22
+ */
23
+ function isCommandAvailable(command) {
24
+ try {
25
+ const isWindows = process.platform === 'win32';
26
+ const checkCmd = isWindows ? 'where' : 'which';
27
+ execSync(`${checkCmd} ${command}`, { stdio: ['pipe', 'pipe', 'pipe'] });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Get the shell to use for command execution
36
+ * @returns {{shell: string, shellArgs: string[]}} Shell path and args
37
+ */
38
+ function getShell() {
39
+ const isWindows = process.platform === 'win32';
40
+ const shell = isWindows ? 'cmd.exe' : process.env.SHELL || '/bin/sh';
41
+ const shellArg = isWindows ? '/c' : '-c';
42
+ return { shell, shellArg };
43
+ }
44
+
45
+ /**
46
+ * Run command in GNU Screen
47
+ * @param {string} command - Command to execute
48
+ * @param {object} options - Options (session, detached)
49
+ * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
50
+ */
51
+ function runInScreen(command, options = {}) {
52
+ if (!isCommandAvailable('screen')) {
53
+ return Promise.resolve({
54
+ success: false,
55
+ sessionName: null,
56
+ message:
57
+ 'screen is not installed. Install it with: sudo apt-get install screen (Debian/Ubuntu) or brew install screen (macOS)',
58
+ });
59
+ }
60
+
61
+ const sessionName = options.session || generateSessionName('screen');
62
+ const { shell, shellArg } = getShell();
63
+
64
+ try {
65
+ if (options.detached) {
66
+ // Detached mode: screen -dmS <session> <shell> -c '<command>'
67
+ const screenArgs = ['-dmS', sessionName, shell, shellArg, command];
68
+
69
+ if (DEBUG) {
70
+ console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
71
+ }
72
+
73
+ execSync(`screen ${screenArgs.map((a) => `"${a}"`).join(' ')}`, {
74
+ stdio: 'inherit',
75
+ });
76
+
77
+ return Promise.resolve({
78
+ success: true,
79
+ sessionName,
80
+ message: `Command started in detached screen session: ${sessionName}\nReattach with: screen -r ${sessionName}`,
81
+ });
82
+ } else {
83
+ // Attached mode: screen -S <session> <shell> -c '<command>'
84
+ const screenArgs = ['-S', sessionName, shell, shellArg, command];
85
+
86
+ if (DEBUG) {
87
+ console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
88
+ }
89
+
90
+ return new Promise((resolve) => {
91
+ const child = spawn('screen', screenArgs, {
92
+ stdio: 'inherit',
93
+ });
94
+
95
+ child.on('exit', (code) => {
96
+ resolve({
97
+ success: code === 0,
98
+ sessionName,
99
+ message: `Screen session "${sessionName}" exited with code ${code}`,
100
+ exitCode: code,
101
+ });
102
+ });
103
+
104
+ child.on('error', (err) => {
105
+ resolve({
106
+ success: false,
107
+ sessionName,
108
+ message: `Failed to start screen: ${err.message}`,
109
+ });
110
+ });
111
+ });
112
+ }
113
+ } catch (err) {
114
+ return Promise.resolve({
115
+ success: false,
116
+ sessionName,
117
+ message: `Failed to run in screen: ${err.message}`,
118
+ });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Run command in tmux
124
+ * @param {string} command - Command to execute
125
+ * @param {object} options - Options (session, detached)
126
+ * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
127
+ */
128
+ function runInTmux(command, options = {}) {
129
+ if (!isCommandAvailable('tmux')) {
130
+ return Promise.resolve({
131
+ success: false,
132
+ sessionName: null,
133
+ message:
134
+ 'tmux is not installed. Install it with: sudo apt-get install tmux (Debian/Ubuntu) or brew install tmux (macOS)',
135
+ });
136
+ }
137
+
138
+ const sessionName = options.session || generateSessionName('tmux');
139
+
140
+ try {
141
+ if (options.detached) {
142
+ // Detached mode: tmux new-session -d -s <session> '<command>'
143
+ if (DEBUG) {
144
+ console.log(
145
+ `[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${command}"`
146
+ );
147
+ }
148
+
149
+ execSync(`tmux new-session -d -s "${sessionName}" "${command}"`, {
150
+ stdio: 'inherit',
151
+ });
152
+
153
+ return Promise.resolve({
154
+ success: true,
155
+ sessionName,
156
+ message: `Command started in detached tmux session: ${sessionName}\nReattach with: tmux attach -t ${sessionName}`,
157
+ });
158
+ } else {
159
+ // Attached mode: tmux new-session -s <session> '<command>'
160
+ if (DEBUG) {
161
+ console.log(
162
+ `[DEBUG] Running: tmux new-session -s "${sessionName}" "${command}"`
163
+ );
164
+ }
165
+
166
+ return new Promise((resolve) => {
167
+ const child = spawn(
168
+ 'tmux',
169
+ ['new-session', '-s', sessionName, command],
170
+ {
171
+ stdio: 'inherit',
172
+ }
173
+ );
174
+
175
+ child.on('exit', (code) => {
176
+ resolve({
177
+ success: code === 0,
178
+ sessionName,
179
+ message: `Tmux session "${sessionName}" exited with code ${code}`,
180
+ exitCode: code,
181
+ });
182
+ });
183
+
184
+ child.on('error', (err) => {
185
+ resolve({
186
+ success: false,
187
+ sessionName,
188
+ message: `Failed to start tmux: ${err.message}`,
189
+ });
190
+ });
191
+ });
192
+ }
193
+ } catch (err) {
194
+ return Promise.resolve({
195
+ success: false,
196
+ sessionName,
197
+ message: `Failed to run in tmux: ${err.message}`,
198
+ });
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Run command in Zellij
204
+ * @param {string} command - Command to execute
205
+ * @param {object} options - Options (session, detached)
206
+ * @returns {Promise<{success: boolean, sessionName: string, message: string}>}
207
+ */
208
+ function runInZellij(command, options = {}) {
209
+ if (!isCommandAvailable('zellij')) {
210
+ return Promise.resolve({
211
+ success: false,
212
+ sessionName: null,
213
+ message:
214
+ 'zellij is not installed. Install it with: cargo install zellij or brew install zellij (macOS)',
215
+ });
216
+ }
217
+
218
+ const sessionName = options.session || generateSessionName('zellij');
219
+ const { shell, shellArg } = getShell();
220
+
221
+ try {
222
+ if (options.detached) {
223
+ // Detached mode for zellij
224
+ if (DEBUG) {
225
+ console.log(`[DEBUG] Creating detached zellij session: ${sessionName}`);
226
+ }
227
+
228
+ // Create the session in background
229
+ execSync(
230
+ `zellij -s "${sessionName}" action new-tab -- ${shell} ${shellArg} "${command}" &`,
231
+ { stdio: 'inherit', shell: true }
232
+ );
233
+
234
+ return Promise.resolve({
235
+ success: true,
236
+ sessionName,
237
+ message: `Command started in detached zellij session: ${sessionName}\nReattach with: zellij attach ${sessionName}`,
238
+ });
239
+ } else {
240
+ // Attached mode: zellij -s <session> -- <shell> -c <command>
241
+ if (DEBUG) {
242
+ console.log(
243
+ `[DEBUG] Running: zellij -s "${sessionName}" -- ${shell} ${shellArg} "${command}"`
244
+ );
245
+ }
246
+
247
+ return new Promise((resolve) => {
248
+ const child = spawn(
249
+ 'zellij',
250
+ ['-s', sessionName, '--', shell, shellArg, command],
251
+ {
252
+ stdio: 'inherit',
253
+ }
254
+ );
255
+
256
+ child.on('exit', (code) => {
257
+ resolve({
258
+ success: code === 0,
259
+ sessionName,
260
+ message: `Zellij session "${sessionName}" exited with code ${code}`,
261
+ exitCode: code,
262
+ });
263
+ });
264
+
265
+ child.on('error', (err) => {
266
+ resolve({
267
+ success: false,
268
+ sessionName,
269
+ message: `Failed to start zellij: ${err.message}`,
270
+ });
271
+ });
272
+ });
273
+ }
274
+ } catch (err) {
275
+ return Promise.resolve({
276
+ success: false,
277
+ sessionName,
278
+ message: `Failed to run in zellij: ${err.message}`,
279
+ });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Run command in Docker container
285
+ * @param {string} command - Command to execute
286
+ * @param {object} options - Options (image, session/name, detached)
287
+ * @returns {Promise<{success: boolean, containerName: string, message: string}>}
288
+ */
289
+ function runInDocker(command, options = {}) {
290
+ if (!isCommandAvailable('docker')) {
291
+ return Promise.resolve({
292
+ success: false,
293
+ containerName: null,
294
+ message:
295
+ 'docker is not installed. Install Docker from https://docs.docker.com/get-docker/',
296
+ });
297
+ }
298
+
299
+ if (!options.image) {
300
+ return Promise.resolve({
301
+ success: false,
302
+ containerName: null,
303
+ message: 'Docker isolation requires --image option',
304
+ });
305
+ }
306
+
307
+ const containerName = options.session || generateSessionName('docker');
308
+
309
+ try {
310
+ if (options.detached) {
311
+ // Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
312
+ const dockerArgs = [
313
+ 'run',
314
+ '-d',
315
+ '--name',
316
+ containerName,
317
+ options.image,
318
+ '/bin/sh',
319
+ '-c',
320
+ command,
321
+ ];
322
+
323
+ if (DEBUG) {
324
+ console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
325
+ }
326
+
327
+ const containerId = execSync(`docker ${dockerArgs.join(' ')}`, {
328
+ encoding: 'utf8',
329
+ }).trim();
330
+
331
+ return Promise.resolve({
332
+ success: true,
333
+ containerName,
334
+ containerId,
335
+ message: `Command started in detached docker container: ${containerName}\nContainer ID: ${containerId.substring(0, 12)}\nAttach with: docker attach ${containerName}\nView logs: docker logs ${containerName}`,
336
+ });
337
+ } else {
338
+ // Attached mode: docker run -it --name <name> <image> <shell> -c '<command>'
339
+ const dockerArgs = [
340
+ 'run',
341
+ '-it',
342
+ '--rm',
343
+ '--name',
344
+ containerName,
345
+ options.image,
346
+ '/bin/sh',
347
+ '-c',
348
+ command,
349
+ ];
350
+
351
+ if (DEBUG) {
352
+ console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
353
+ }
354
+
355
+ return new Promise((resolve) => {
356
+ const child = spawn('docker', dockerArgs, {
357
+ stdio: 'inherit',
358
+ });
359
+
360
+ child.on('exit', (code) => {
361
+ resolve({
362
+ success: code === 0,
363
+ containerName,
364
+ message: `Docker container "${containerName}" exited with code ${code}`,
365
+ exitCode: code,
366
+ });
367
+ });
368
+
369
+ child.on('error', (err) => {
370
+ resolve({
371
+ success: false,
372
+ containerName,
373
+ message: `Failed to start docker: ${err.message}`,
374
+ });
375
+ });
376
+ });
377
+ }
378
+ } catch (err) {
379
+ return Promise.resolve({
380
+ success: false,
381
+ containerName,
382
+ message: `Failed to run in docker: ${err.message}`,
383
+ });
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Run command in the specified isolation backend
389
+ * @param {string} backend - Isolation backend (screen, tmux, docker, zellij)
390
+ * @param {string} command - Command to execute
391
+ * @param {object} options - Options
392
+ * @returns {Promise<{success: boolean, message: string}>}
393
+ */
394
+ function runIsolated(backend, command, options = {}) {
395
+ switch (backend) {
396
+ case 'screen':
397
+ return runInScreen(command, options);
398
+ case 'tmux':
399
+ return runInTmux(command, options);
400
+ case 'zellij':
401
+ return runInZellij(command, options);
402
+ case 'docker':
403
+ return runInDocker(command, options);
404
+ default:
405
+ return Promise.resolve({
406
+ success: false,
407
+ message: `Unknown isolation backend: ${backend}`,
408
+ });
409
+ }
410
+ }
411
+
412
+ module.exports = {
413
+ isCommandAvailable,
414
+ runInScreen,
415
+ runInTmux,
416
+ runInZellij,
417
+ runInDocker,
418
+ runIsolated,
419
+ };