start-command 0.11.0 → 0.15.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -217
  2. package/bun.lock +10 -0
  3. package/eslint.config.mjs +1 -1
  4. package/package.json +13 -5
  5. package/src/bin/cli.js +414 -499
  6. package/src/lib/args-parser.js +126 -0
  7. package/src/lib/command-stream.js +258 -0
  8. package/src/lib/execution-store.js +722 -0
  9. package/src/lib/failure-handler.js +397 -0
  10. package/src/lib/isolation.js +51 -0
  11. package/src/lib/status-formatter.js +121 -0
  12. package/src/lib/version.js +143 -0
  13. package/test/args-parser.test.js +140 -0
  14. package/test/cli.test.js +11 -1
  15. package/test/docker-autoremove.test.js +11 -16
  16. package/test/execution-store.test.js +483 -0
  17. package/test/isolation-cleanup.test.js +11 -16
  18. package/test/isolation.test.js +11 -17
  19. package/test/public-exports.test.js +105 -0
  20. package/test/status-query.test.js +195 -0
  21. package/.github/workflows/release.yml +0 -334
  22. package/.husky/pre-commit +0 -1
  23. package/ARCHITECTURE.md +0 -297
  24. package/LICENSE +0 -24
  25. package/README.md +0 -339
  26. package/REQUIREMENTS.md +0 -299
  27. package/docs/PIPES.md +0 -243
  28. package/docs/USAGE.md +0 -194
  29. package/docs/case-studies/issue-15/README.md +0 -208
  30. package/docs/case-studies/issue-18/README.md +0 -343
  31. package/docs/case-studies/issue-18/issue-comments.json +0 -1
  32. package/docs/case-studies/issue-18/issue-data.json +0 -7
  33. package/docs/case-studies/issue-22/analysis.md +0 -547
  34. package/docs/case-studies/issue-22/issue-data.json +0 -12
  35. package/docs/case-studies/issue-25/README.md +0 -232
  36. package/docs/case-studies/issue-25/issue-data.json +0 -21
  37. package/docs/case-studies/issue-28/README.md +0 -405
  38. package/docs/case-studies/issue-28/issue-data.json +0 -105
  39. package/docs/case-studies/issue-28/raw-issue-data.md +0 -92
  40. package/experiments/debug-regex.js +0 -49
  41. package/experiments/isolation-design.md +0 -131
  42. package/experiments/screen-output-test.js +0 -265
  43. package/experiments/test-cli.sh +0 -42
  44. package/experiments/test-screen-attached.js +0 -126
  45. package/experiments/test-screen-logfile.js +0 -286
  46. package/experiments/test-screen-modes.js +0 -128
  47. package/experiments/test-screen-output.sh +0 -27
  48. package/experiments/test-screen-tee-debug.js +0 -237
  49. package/experiments/test-screen-tee-fallback.js +0 -230
  50. package/experiments/test-substitution.js +0 -143
  51. package/experiments/user-isolation-research.md +0 -83
  52. package/scripts/changeset-version.mjs +0 -38
  53. package/scripts/check-file-size.mjs +0 -103
  54. package/scripts/create-github-release.mjs +0 -93
  55. package/scripts/create-manual-changeset.mjs +0 -89
  56. package/scripts/format-github-release.mjs +0 -83
  57. package/scripts/format-release-notes.mjs +0 -219
  58. package/scripts/instant-version-bump.mjs +0 -121
  59. package/scripts/publish-to-npm.mjs +0 -129
  60. package/scripts/setup-npm.mjs +0 -37
  61. package/scripts/validate-changeset.mjs +0 -107
  62. package/scripts/version-and-commit.mjs +0 -237
@@ -16,6 +16,9 @@
16
16
  * --keep-user Keep isolated user after command completes (don't delete)
17
17
  * --keep-alive, -k Keep isolation environment alive after command exits
18
18
  * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
19
+ * --use-command-stream Use command-stream library for command execution (experimental)
20
+ * --status <uuid> Show status of a previous command execution by UUID
21
+ * --output-format <format> Output format for status (links-notation, json, text)
19
22
  */
20
23
 
21
24
  // Debug mode from environment
@@ -27,6 +30,45 @@ const DEBUG =
27
30
  */
28
31
  const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];
29
32
 
33
+ /**
34
+ * Valid output formats for --status
35
+ */
36
+ const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
37
+
38
+ /**
39
+ * UUID v4 regex pattern for validation
40
+ */
41
+ const UUID_REGEX =
42
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
43
+
44
+ /**
45
+ * Check if a string is a valid UUID v4
46
+ * @param {string} str - String to validate
47
+ * @returns {boolean} True if valid UUID v4
48
+ */
49
+ function isValidUUID(str) {
50
+ return UUID_REGEX.test(str);
51
+ }
52
+
53
+ /**
54
+ * Generate a UUID v4
55
+ * @returns {string} A new UUID v4 string
56
+ */
57
+ function generateUUID() {
58
+ // Try to use Node.js/Bun crypto module
59
+ try {
60
+ const crypto = require('crypto');
61
+ return crypto.randomUUID();
62
+ } catch {
63
+ // Fallback for environments without crypto.randomUUID
64
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
65
+ const r = (Math.random() * 16) | 0;
66
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
67
+ return v.toString(16);
68
+ });
69
+ }
70
+ }
71
+
30
72
  /**
31
73
  * Parse command line arguments into wrapper options and command
32
74
  * @param {string[]} args - Array of command line arguments
@@ -38,6 +80,7 @@ function parseArgs(args) {
38
80
  attached: false, // Run in attached mode
39
81
  detached: false, // Run in detached mode
40
82
  session: null, // Session name
83
+ sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided
41
84
  image: null, // Docker image
42
85
  endpoint: null, // SSH endpoint (e.g., user@host)
43
86
  user: false, // Create isolated user
@@ -45,6 +88,9 @@ function parseArgs(args) {
45
88
  keepUser: false, // Keep isolated user after command completes (don't delete)
46
89
  keepAlive: false, // Keep environment alive after command exits
47
90
  autoRemoveDockerContainer: false, // Auto-remove docker container after exit
91
+ useCommandStream: false, // Use command-stream library for command execution
92
+ status: null, // UUID to show status for
93
+ outputFormat: null, // Output format for status (links-notation, json, text)
48
94
  };
49
95
 
50
96
  let commandArgs = [];
@@ -239,6 +285,60 @@ function parseOption(args, index, options) {
239
285
  return 1;
240
286
  }
241
287
 
288
+ // --use-command-stream
289
+ if (arg === '--use-command-stream') {
290
+ options.useCommandStream = true;
291
+ return 1;
292
+ }
293
+
294
+ // --session-id or --session-name (alias) <uuid>
295
+ if (arg === '--session-id' || arg === '--session-name') {
296
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
297
+ options.sessionId = args[index + 1];
298
+ return 2;
299
+ } else {
300
+ throw new Error(`Option ${arg} requires a UUID argument`);
301
+ }
302
+ }
303
+
304
+ // --session-id=<value> or --session-name=<value>
305
+ if (arg.startsWith('--session-id=') || arg.startsWith('--session-name=')) {
306
+ options.sessionId = arg.split('=')[1];
307
+ return 1;
308
+ }
309
+
310
+ // --status <uuid>
311
+ if (arg === '--status') {
312
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
313
+ options.status = args[index + 1];
314
+ return 2;
315
+ } else {
316
+ throw new Error(`Option ${arg} requires a UUID argument`);
317
+ }
318
+ }
319
+
320
+ // --status=<value>
321
+ if (arg.startsWith('--status=')) {
322
+ options.status = arg.split('=')[1];
323
+ return 1;
324
+ }
325
+
326
+ // --output-format <format>
327
+ if (arg === '--output-format') {
328
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
329
+ options.outputFormat = args[index + 1].toLowerCase();
330
+ return 2;
331
+ } else {
332
+ throw new Error(`Option ${arg} requires a format argument`);
333
+ }
334
+ }
335
+
336
+ // --output-format=<value>
337
+ if (arg.startsWith('--output-format=')) {
338
+ options.outputFormat = arg.split('=')[1].toLowerCase();
339
+ return 1;
340
+ }
341
+
242
342
  // Not a recognized wrapper option
243
343
  return 0;
244
344
  }
@@ -333,6 +433,29 @@ function validateOptions(options) {
333
433
  if (options.keepUser && !options.user) {
334
434
  throw new Error('--keep-user option is only valid with --isolated-user');
335
435
  }
436
+
437
+ // Validate output format
438
+ if (options.outputFormat !== null && options.outputFormat !== undefined) {
439
+ if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
440
+ throw new Error(
441
+ `Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
442
+ );
443
+ }
444
+ }
445
+
446
+ // Output format is only valid with --status
447
+ if (options.outputFormat && !options.status) {
448
+ throw new Error('--output-format option is only valid with --status');
449
+ }
450
+
451
+ // Validate session ID is a valid UUID if provided
452
+ if (options.sessionId !== null && options.sessionId !== undefined) {
453
+ if (!isValidUUID(options.sessionId)) {
454
+ throw new Error(
455
+ `Invalid session ID: "${options.sessionId}". Session ID must be a valid UUID v4.`
456
+ );
457
+ }
458
+ }
336
459
  }
337
460
 
338
461
  /**
@@ -375,5 +498,8 @@ module.exports = {
375
498
  generateSessionName,
376
499
  hasIsolation,
377
500
  getEffectiveMode,
501
+ isValidUUID,
502
+ generateUUID,
378
503
  VALID_BACKENDS,
504
+ VALID_OUTPUT_FORMATS,
379
505
  };
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Command-Stream Wrapper for start-command
3
+ *
4
+ * This module provides a bridge to the command-stream library, which uses ESM,
5
+ * from the CommonJS-based start-command codebase.
6
+ *
7
+ * The command-stream library provides:
8
+ * - Shell command execution with streaming support
9
+ * - Synchronous and asynchronous execution modes
10
+ * - Built-in virtual commands (echo, ls, pwd, cd, etc.)
11
+ * - Real-time output capture
12
+ */
13
+
14
+ // Debug mode from environment
15
+ const DEBUG =
16
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
17
+
18
+ // Cached command-stream module
19
+ let commandStream = null;
20
+
21
+ /**
22
+ * Get the command-stream module (lazy-loaded)
23
+ * @returns {Promise<object>} The command-stream module
24
+ */
25
+ async function getCommandStream() {
26
+ if (!commandStream) {
27
+ commandStream = await import('command-stream');
28
+ }
29
+ return commandStream;
30
+ }
31
+
32
+ /**
33
+ * Execute a shell command synchronously and return the result
34
+ * Uses command-stream's $ function with .sync() for blocking execution.
35
+ *
36
+ * @param {string} command - The shell command to execute
37
+ * @param {object} options - Options for command execution
38
+ * @param {boolean} options.silent - If true, don't mirror output to console (default: true)
39
+ * @param {boolean} options.captureOutput - If true, capture stdout/stderr (default: true)
40
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>} Command result
41
+ */
42
+ async function execCommand(command, options = {}) {
43
+ const { $ } = await getCommandStream();
44
+
45
+ const silent = options.silent !== false;
46
+
47
+ // Create a configured $ instance
48
+ const $cmd = $({ mirror: !silent, capture: true });
49
+
50
+ try {
51
+ // Use sync() for synchronous execution
52
+ const result = $cmd`${command}`.sync();
53
+
54
+ return {
55
+ stdout: (result.stdout || '').trim(),
56
+ stderr: (result.stderr || '').trim(),
57
+ code: result.code || 0,
58
+ };
59
+ } catch (err) {
60
+ if (DEBUG) {
61
+ console.log(`[DEBUG] execCommand error: ${err.message}`);
62
+ }
63
+ return {
64
+ stdout: '',
65
+ stderr: err.message || '',
66
+ code: 1,
67
+ };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Execute a shell command asynchronously and return the result
73
+ *
74
+ * @param {string} command - The shell command to execute
75
+ * @param {object} options - Options for command execution
76
+ * @param {boolean} options.silent - If true, don't mirror output to console (default: true)
77
+ * @param {boolean} options.captureOutput - If true, capture stdout/stderr (default: true)
78
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>} Command result
79
+ */
80
+ async function execCommandAsync(command, options = {}) {
81
+ const { $ } = await getCommandStream();
82
+
83
+ const silent = options.silent !== false;
84
+
85
+ // Create a configured $ instance
86
+ const $cmd = $({ mirror: !silent, capture: true });
87
+
88
+ try {
89
+ const result = await $cmd`${command}`;
90
+
91
+ return {
92
+ stdout: (result.stdout || '').trim(),
93
+ stderr: (result.stderr || '').trim(),
94
+ code: result.code || 0,
95
+ };
96
+ } catch (err) {
97
+ if (DEBUG) {
98
+ console.log(`[DEBUG] execCommandAsync error: ${err.message}`);
99
+ }
100
+ return {
101
+ stdout: '',
102
+ stderr: err.message || '',
103
+ code: 1,
104
+ };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Check if a command exists in the PATH
110
+ *
111
+ * @param {string} commandName - The command to check for
112
+ * @returns {Promise<boolean>} True if the command exists
113
+ */
114
+ async function commandExists(commandName) {
115
+ const isWindows = process.platform === 'win32';
116
+ const whichCmd = isWindows ? 'where' : 'which';
117
+
118
+ const result = await execCommand(`${whichCmd} ${commandName}`);
119
+ return result.code === 0;
120
+ }
121
+
122
+ /**
123
+ * Get the path to a command
124
+ *
125
+ * @param {string} commandName - The command to find
126
+ * @returns {Promise<string|null>} Path to the command or null if not found
127
+ */
128
+ async function getCommandPath(commandName) {
129
+ const isWindows = process.platform === 'win32';
130
+ const whichCmd = isWindows ? 'where' : 'which';
131
+
132
+ const result = await execCommand(`${whichCmd} ${commandName}`);
133
+ if (result.code === 0 && result.stdout) {
134
+ // On Windows, where returns multiple lines, take the first
135
+ return result.stdout.split('\n')[0].trim();
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Get the version of a tool by running it with a version flag
142
+ *
143
+ * @param {string} toolName - Name of the tool
144
+ * @param {string} versionFlag - Flag to get version (e.g., '--version', '-V')
145
+ * @param {boolean} verbose - Whether to log verbose information
146
+ * @returns {Promise<string|null>} Version string or null if not installed
147
+ */
148
+ async function getToolVersion(toolName, versionFlag, verbose = false) {
149
+ // First check if the tool exists
150
+ const exists = await commandExists(toolName);
151
+ if (!exists) {
152
+ if (verbose) {
153
+ console.log(`[verbose] ${toolName}: not found in PATH`);
154
+ }
155
+ return null;
156
+ }
157
+
158
+ // Get the version - command-stream handles the output capture
159
+ const result = await execCommand(`${toolName} ${versionFlag}`);
160
+
161
+ // Combine stdout and stderr since some tools output version to stderr
162
+ const output = `${result.stdout}\n${result.stderr}`.trim();
163
+
164
+ if (verbose) {
165
+ console.log(
166
+ `[verbose] ${toolName} ${versionFlag}: exit=${result.code}, output="${output.substring(0, 100)}"`
167
+ );
168
+ }
169
+
170
+ if (!output) {
171
+ return null;
172
+ }
173
+
174
+ // Return the first line of output
175
+ const firstLine = output.split('\n')[0];
176
+ return firstLine || null;
177
+ }
178
+
179
+ /**
180
+ * Run a command with real-time output streaming
181
+ * This returns a ProcessRunner that can be used for advanced control.
182
+ *
183
+ * @param {string} command - The shell command to execute
184
+ * @param {object} options - Options for command execution
185
+ * @param {boolean} options.mirror - If true, mirror output to console (default: true)
186
+ * @param {boolean} options.capture - If true, capture output (default: true)
187
+ * @param {string} options.stdin - Input to pass to the command
188
+ * @param {string} options.cwd - Working directory
189
+ * @param {object} options.env - Environment variables
190
+ * @returns {Promise<ProcessRunner>} The process runner for the command
191
+ */
192
+ async function runCommand(command, options = {}) {
193
+ const { $ } = await getCommandStream();
194
+
195
+ const $cmd = $({
196
+ mirror: options.mirror !== false,
197
+ capture: options.capture !== false,
198
+ stdin: options.stdin,
199
+ cwd: options.cwd,
200
+ env: options.env,
201
+ });
202
+
203
+ // Return the process runner
204
+ return $cmd`${command}`;
205
+ }
206
+
207
+ /**
208
+ * Run a command with event handlers for stdout, stderr, and exit
209
+ *
210
+ * @param {string} command - The shell command to execute
211
+ * @param {object} handlers - Event handlers
212
+ * @param {function} handlers.onStdout - Called with stdout data chunks
213
+ * @param {function} handlers.onStderr - Called with stderr data chunks
214
+ * @param {function} handlers.onExit - Called when command exits with {code, stdout, stderr}
215
+ * @param {object} options - Additional options
216
+ * @param {boolean} options.mirror - If true, also mirror output to console
217
+ * @returns {Promise<void>}
218
+ */
219
+ async function runWithHandlers(command, handlers = {}, options = {}) {
220
+ const { $ } = await getCommandStream();
221
+
222
+ const { onStdout, onStderr, onExit } = handlers;
223
+
224
+ const $cmd = $({
225
+ mirror: options.mirror === true,
226
+ capture: true,
227
+ });
228
+
229
+ const runner = $cmd`${command}`;
230
+
231
+ // Set up event handlers
232
+ if (onStdout) {
233
+ runner.on('stdout', onStdout);
234
+ }
235
+ if (onStderr) {
236
+ runner.on('stderr', onStderr);
237
+ }
238
+ if (onExit) {
239
+ runner.on('end', onExit);
240
+ }
241
+
242
+ // Start the command
243
+ runner.start();
244
+
245
+ // Wait for completion
246
+ return await runner;
247
+ }
248
+
249
+ module.exports = {
250
+ getCommandStream,
251
+ execCommand,
252
+ execCommandAsync,
253
+ commandExists,
254
+ getCommandPath,
255
+ getToolVersion,
256
+ runCommand,
257
+ runWithHandlers,
258
+ };