start-command 0.17.2 → 0.17.3

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,20 @@
1
1
  # start-command
2
2
 
3
+ ## 0.17.3
4
+
5
+ ### Patch Changes
6
+
7
+ - a61f1a9: fix: Use Bun.spawn for reliable stdout capture on macOS (Issue #57)
8
+
9
+ The previous fix (v0.17.2) using `close` event instead of `exit` did not resolve the issue on macOS. After deeper investigation, we discovered the root cause: Bun's event loop may exit before the `close` event callback can be scheduled, especially for fast commands like `echo`.
10
+
11
+ This fix uses Bun's native `Bun.spawn` API with async/await for stream handling when running on Bun runtime. This approach keeps the event loop alive until all streams are consumed and the process exits.
12
+ - Use `Bun.spawn` instead of `node:child_process` when running on Bun
13
+ - Use async stream readers with `getReader()` for real-time output display
14
+ - Use `await proc.exited` to ensure process completion before exiting
15
+ - Fall back to `node:child_process` with `close` event for Node.js compatibility
16
+ - Add verbose logging with `--verbose` flag for debugging
17
+
3
18
  ## 0.17.2
4
19
 
5
20
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
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": {
package/src/bin/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- const { spawn } = require('child_process');
4
3
  const process = require('process');
5
4
  const os = require('os');
6
5
  const fs = require('fs');
@@ -35,6 +34,7 @@ const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
35
34
  const { queryStatus } = require('../lib/status-formatter');
36
35
  const { printVersion } = require('../lib/version');
37
36
  const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
37
+ const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
38
38
 
39
39
  // Configuration from environment variables
40
40
  const config = {
@@ -529,11 +529,18 @@ async function runWithIsolation(
529
529
  }
530
530
 
531
531
  /**
532
- * Run command directly (without isolation) - original synchronous version
532
+ * Run command directly (without isolation)
533
+ *
534
+ * Uses Bun.spawn when running on Bun for reliable event handling on macOS.
535
+ * Falls back to node:child_process for Node.js compatibility.
536
+ *
537
+ * Issue #57: On macOS with Bun, node:child_process events may not fire reliably
538
+ * before the event loop exits. Bun.spawn provides more reliable stream handling.
539
+ *
533
540
  * @param {string} cmd - Command to execute
534
541
  * @param {string} sessionId - Session UUID for tracking
535
542
  */
536
- function runDirect(cmd, sessionId) {
543
+ async function runDirect(cmd, sessionId) {
537
544
  // Get the command name (first word of the actual command to execute)
538
545
  const commandName = cmd.split(' ')[0];
539
546
 
@@ -606,76 +613,8 @@ function runDirect(cmd, sessionId) {
606
613
  );
607
614
  console.log('');
608
615
 
609
- // Execute the command with captured output
610
- const child = spawn(shell, shellArgs, {
611
- stdio: ['inherit', 'pipe', 'pipe'],
612
- shell: false,
613
- });
614
-
615
- // Update execution record with PID and save initial state
616
- if (executionRecord && store) {
617
- executionRecord.pid = child.pid;
618
- try {
619
- store.save(executionRecord);
620
- } catch (err) {
621
- if (config.verbose) {
622
- console.error(
623
- `[Tracking] Warning: Could not save execution record: ${err.message}`
624
- );
625
- }
626
- }
627
- }
628
-
629
- // Capture stdout
630
- child.stdout.on('data', (data) => {
631
- const text = data.toString();
632
- process.stdout.write(text);
633
- logContent += text;
634
- });
635
-
636
- // Capture stderr
637
- child.stderr.on('data', (data) => {
638
- const text = data.toString();
639
- process.stderr.write(text);
640
- logContent += text;
641
- });
642
-
643
- // Handle process close (not 'exit' - we need to wait for all stdio to be closed)
644
- // The 'close' event fires after all stdio streams have been closed, ensuring
645
- // all stdout/stderr data has been received. The 'exit' event can fire before
646
- // buffered data is received, causing output loss on macOS (Issue #57).
647
- child.on('close', (code) => {
648
- const exitCode = code || 0;
649
- const endTime = getTimestamp();
650
-
651
- // Log footer
652
- logContent += `\n${'='.repeat(50)}\n`;
653
- logContent += `Finished: ${endTime}\n`;
654
- logContent += `Exit Code: ${exitCode}\n`;
655
-
656
- // Write log file
657
- try {
658
- fs.writeFileSync(logFilePath, logContent, 'utf8');
659
- } catch (err) {
660
- console.error(`\nWarning: Could not save log file: ${err.message}`);
661
- }
662
-
663
- // Update execution record as completed
664
- if (executionRecord && store) {
665
- executionRecord.complete(exitCode);
666
- try {
667
- store.save(executionRecord);
668
- } catch (err) {
669
- if (config.verbose) {
670
- console.error(
671
- `[Tracking] Warning: Could not update execution record: ${err.message}`
672
- );
673
- }
674
- }
675
- }
676
-
677
- // Print finish block
678
- const durationMs = Date.now() - startTimeMs;
616
+ // Completion callback
617
+ const onComplete = (exitCode, endTime, _logContent, durationMs) => {
679
618
  console.log('');
680
619
  console.log(
681
620
  createFinishBlock({
@@ -686,47 +625,14 @@ function runDirect(cmd, sessionId) {
686
625
  durationMs,
687
626
  })
688
627
  );
689
-
690
- // If command failed, try to auto-report
691
628
  if (exitCode !== 0) {
692
629
  handleFailure(config, commandName, cmd, exitCode, logFilePath);
693
630
  }
694
-
695
631
  process.exit(exitCode);
696
- });
697
-
698
- // Handle spawn errors
699
- child.on('error', (err) => {
700
- const endTime = getTimestamp();
701
- const durationMs = Date.now() - startTimeMs;
702
- const errorMessage = `Error executing command: ${err.message}`;
703
-
704
- logContent += `\n${errorMessage}\n`;
705
- logContent += `\n${'='.repeat(50)}\n`;
706
- logContent += `Finished: ${endTime}\n`;
707
- logContent += `Exit Code: 1\n`;
708
-
709
- // Write log file
710
- try {
711
- fs.writeFileSync(logFilePath, logContent, 'utf8');
712
- } catch (writeErr) {
713
- console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
714
- }
715
-
716
- // Update execution record as failed
717
- if (executionRecord && store) {
718
- executionRecord.complete(1);
719
- try {
720
- store.save(executionRecord);
721
- } catch (storeErr) {
722
- if (config.verbose) {
723
- console.error(
724
- `[Tracking] Warning: Could not update execution record: ${storeErr.message}`
725
- );
726
- }
727
- }
728
- }
632
+ };
729
633
 
634
+ // Error callback
635
+ const onError = (errorMessage, endTime, durationMs) => {
730
636
  console.error(`\n${errorMessage}`);
731
637
  console.log('');
732
638
  console.log(
@@ -738,11 +644,30 @@ function runDirect(cmd, sessionId) {
738
644
  durationMs,
739
645
  })
740
646
  );
741
-
742
647
  handleFailure(config, commandName, cmd, 1, logFilePath);
743
-
744
648
  process.exit(1);
745
- });
649
+ };
650
+
651
+ // Use Bun.spawn when running on Bun for reliable event handling on macOS
652
+ // Fall back to node:child_process for Node.js compatibility
653
+ const spawnOptions = {
654
+ shell,
655
+ shellArgs,
656
+ logFilePath,
657
+ logContent,
658
+ startTimeMs,
659
+ executionRecord,
660
+ store,
661
+ config,
662
+ onComplete,
663
+ onError,
664
+ };
665
+
666
+ if (typeof Bun !== 'undefined') {
667
+ await runWithBunSpawn(spawnOptions);
668
+ } else {
669
+ runWithNodeSpawn(spawnOptions);
670
+ }
746
671
  }
747
672
 
748
673
  /**
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Spawn helper functions for reliable cross-platform command execution
3
+ *
4
+ * Issue #57: On macOS with Bun, node:child_process events may not fire reliably
5
+ * before the event loop exits. Bun.spawn provides more reliable stream handling.
6
+ *
7
+ * This module provides two implementations:
8
+ * - runWithBunSpawn: Uses Bun.spawn with async/await for reliable event handling
9
+ * - runWithNodeSpawn: Uses node:child_process with close event for Node.js compatibility
10
+ */
11
+
12
+ const { spawn } = require('child_process');
13
+ const fs = require('fs');
14
+
15
+ /**
16
+ * Run command using Bun.spawn (for Bun runtime)
17
+ * Uses async/await for reliable stream handling on macOS
18
+ *
19
+ * @param {Object} options - Execution options
20
+ * @param {string} options.shell - Shell to use
21
+ * @param {string[]} options.shellArgs - Shell arguments
22
+ * @param {string} options.logFilePath - Path to log file
23
+ * @param {string} options.logContent - Initial log content
24
+ * @param {number} options.startTimeMs - Start timestamp
25
+ * @param {Object} options.executionRecord - Execution tracking record
26
+ * @param {Object} options.store - Execution store
27
+ * @param {Object} options.config - CLI configuration
28
+ * @param {Function} options.onComplete - Callback for completion (exitCode, endTime, logContent, durationMs)
29
+ * @param {Function} options.onError - Callback for errors (errorMessage, endTime, durationMs)
30
+ */
31
+ async function runWithBunSpawn(options) {
32
+ const {
33
+ shell,
34
+ shellArgs,
35
+ logFilePath,
36
+ startTimeMs,
37
+ executionRecord,
38
+ store,
39
+ config,
40
+ onComplete,
41
+ onError,
42
+ } = options;
43
+
44
+ let logContent = options.logContent || '';
45
+
46
+ try {
47
+ // Spawn the process using Bun's native API
48
+ const proc = Bun.spawn([shell, ...shellArgs], {
49
+ stdout: 'pipe',
50
+ stderr: 'pipe',
51
+ stdin: 'inherit',
52
+ });
53
+
54
+ // Update execution record with PID and save initial state
55
+ if (executionRecord && store) {
56
+ executionRecord.pid = proc.pid;
57
+ try {
58
+ store.save(executionRecord);
59
+ } catch (err) {
60
+ if (config && config.verbose) {
61
+ console.error(
62
+ `[Tracking] Warning: Could not save execution record: ${err.message}`
63
+ );
64
+ }
65
+ }
66
+ }
67
+
68
+ if (config && config.verbose) {
69
+ console.log(`[verbose] Using Bun.spawn for reliable macOS handling`);
70
+ console.log(`[verbose] Process PID: ${proc.pid}`);
71
+ }
72
+
73
+ // Read stdout and stderr streams concurrently
74
+ // TextDecoder is a global in modern runtimes (Bun, Node.js 16+)
75
+ // eslint-disable-next-line no-undef
76
+ const decoder = new TextDecoder();
77
+
78
+ // Read stdout in real-time
79
+ const stdoutReader = proc.stdout.getReader();
80
+ const readStdout = async () => {
81
+ let output = '';
82
+ try {
83
+ while (true) {
84
+ const { done, value } = await stdoutReader.read();
85
+ if (done) {
86
+ break;
87
+ }
88
+ const text = decoder.decode(value);
89
+ process.stdout.write(text);
90
+ output += text;
91
+ }
92
+ } catch (err) {
93
+ if (config && config.verbose) {
94
+ console.error(`[verbose] stdout read error: ${err.message}`);
95
+ }
96
+ }
97
+ return output;
98
+ };
99
+
100
+ // Read stderr in real-time
101
+ const stderrReader = proc.stderr.getReader();
102
+ const readStderr = async () => {
103
+ let output = '';
104
+ try {
105
+ while (true) {
106
+ const { done, value } = await stderrReader.read();
107
+ if (done) {
108
+ break;
109
+ }
110
+ const text = decoder.decode(value);
111
+ process.stderr.write(text);
112
+ output += text;
113
+ }
114
+ } catch (err) {
115
+ if (config && config.verbose) {
116
+ console.error(`[verbose] stderr read error: ${err.message}`);
117
+ }
118
+ }
119
+ return output;
120
+ };
121
+
122
+ // Read both streams concurrently and wait for process to exit
123
+ const [stdoutContent, stderrContent, exitCode] = await Promise.all([
124
+ readStdout(),
125
+ readStderr(),
126
+ proc.exited,
127
+ ]);
128
+
129
+ // Add captured output to log content
130
+ logContent += stdoutContent;
131
+ logContent += stderrContent;
132
+
133
+ const durationMs = Date.now() - startTimeMs;
134
+ const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
135
+
136
+ // Write log file
137
+ try {
138
+ logContent += `\n${'='.repeat(50)}\n`;
139
+ logContent += `Finished: ${endTime}\n`;
140
+ logContent += `Exit Code: ${exitCode}\n`;
141
+ fs.writeFileSync(logFilePath, logContent, 'utf8');
142
+ } catch (err) {
143
+ console.error(`\nWarning: Could not save log file: ${err.message}`);
144
+ }
145
+
146
+ // Update execution record as completed
147
+ if (executionRecord && store) {
148
+ executionRecord.complete(exitCode);
149
+ try {
150
+ store.save(executionRecord);
151
+ } catch (err) {
152
+ if (config && config.verbose) {
153
+ console.error(
154
+ `[Tracking] Warning: Could not update execution record: ${err.message}`
155
+ );
156
+ }
157
+ }
158
+ }
159
+
160
+ // Call completion callback
161
+ if (onComplete) {
162
+ onComplete(exitCode, endTime, logContent, durationMs);
163
+ }
164
+
165
+ return exitCode;
166
+ } catch (err) {
167
+ const durationMs = Date.now() - startTimeMs;
168
+ const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
169
+ const errorMessage = `Error executing command: ${err.message}`;
170
+
171
+ logContent += `\n${errorMessage}\n`;
172
+ logContent += `\n${'='.repeat(50)}\n`;
173
+ logContent += `Finished: ${endTime}\n`;
174
+ logContent += `Exit Code: 1\n`;
175
+
176
+ // Write log file
177
+ try {
178
+ fs.writeFileSync(logFilePath, logContent, 'utf8');
179
+ } catch (writeErr) {
180
+ console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
181
+ }
182
+
183
+ // Update execution record as failed
184
+ if (executionRecord && store) {
185
+ executionRecord.complete(1);
186
+ try {
187
+ store.save(executionRecord);
188
+ } catch (storeErr) {
189
+ if (config && config.verbose) {
190
+ console.error(
191
+ `[Tracking] Warning: Could not update execution record: ${storeErr.message}`
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ // Call error callback
198
+ if (onError) {
199
+ onError(errorMessage, endTime, durationMs);
200
+ }
201
+
202
+ return 1;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Run command using node:child_process (for Node.js compatibility)
208
+ * Uses event-based handling with close event
209
+ *
210
+ * @param {Object} options - Execution options (same as runWithBunSpawn)
211
+ */
212
+ function runWithNodeSpawn(options) {
213
+ const {
214
+ shell,
215
+ shellArgs,
216
+ logFilePath,
217
+ startTimeMs,
218
+ executionRecord,
219
+ store,
220
+ config,
221
+ onComplete,
222
+ onError,
223
+ } = options;
224
+
225
+ let logContent = options.logContent || '';
226
+
227
+ // Execute the command with captured output
228
+ const child = spawn(shell, shellArgs, {
229
+ stdio: ['inherit', 'pipe', 'pipe'],
230
+ shell: false,
231
+ });
232
+
233
+ // Update execution record with PID and save initial state
234
+ if (executionRecord && store) {
235
+ executionRecord.pid = child.pid;
236
+ try {
237
+ store.save(executionRecord);
238
+ } catch (err) {
239
+ if (config && config.verbose) {
240
+ console.error(
241
+ `[Tracking] Warning: Could not save execution record: ${err.message}`
242
+ );
243
+ }
244
+ }
245
+ }
246
+
247
+ // Capture stdout
248
+ child.stdout.on('data', (data) => {
249
+ const text = data.toString();
250
+ process.stdout.write(text);
251
+ logContent += text;
252
+ });
253
+
254
+ // Capture stderr
255
+ child.stderr.on('data', (data) => {
256
+ const text = data.toString();
257
+ process.stderr.write(text);
258
+ logContent += text;
259
+ });
260
+
261
+ // Handle process close (not 'exit' - we need to wait for all stdio to be closed)
262
+ // The 'close' event fires after all stdio streams have been closed, ensuring
263
+ // all stdout/stderr data has been received. The 'exit' event can fire before
264
+ // buffered data is received, causing output loss on macOS (Issue #57).
265
+ child.on('close', (code) => {
266
+ const exitCode = code || 0;
267
+ const durationMs = Date.now() - startTimeMs;
268
+ const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
269
+
270
+ // Log footer
271
+ logContent += `\n${'='.repeat(50)}\n`;
272
+ logContent += `Finished: ${endTime}\n`;
273
+ logContent += `Exit Code: ${exitCode}\n`;
274
+
275
+ // Write log file
276
+ try {
277
+ fs.writeFileSync(logFilePath, logContent, 'utf8');
278
+ } catch (err) {
279
+ console.error(`\nWarning: Could not save log file: ${err.message}`);
280
+ }
281
+
282
+ // Update execution record as completed
283
+ if (executionRecord && store) {
284
+ executionRecord.complete(exitCode);
285
+ try {
286
+ store.save(executionRecord);
287
+ } catch (err) {
288
+ if (config && config.verbose) {
289
+ console.error(
290
+ `[Tracking] Warning: Could not update execution record: ${err.message}`
291
+ );
292
+ }
293
+ }
294
+ }
295
+
296
+ // Call completion callback
297
+ if (onComplete) {
298
+ onComplete(exitCode, endTime, logContent, durationMs);
299
+ }
300
+ });
301
+
302
+ // Handle spawn errors
303
+ child.on('error', (err) => {
304
+ const durationMs = Date.now() - startTimeMs;
305
+ const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
306
+ const errorMessage = `Error executing command: ${err.message}`;
307
+
308
+ logContent += `\n${errorMessage}\n`;
309
+ logContent += `\n${'='.repeat(50)}\n`;
310
+ logContent += `Finished: ${endTime}\n`;
311
+ logContent += `Exit Code: 1\n`;
312
+
313
+ // Write log file
314
+ try {
315
+ fs.writeFileSync(logFilePath, logContent, 'utf8');
316
+ } catch (writeErr) {
317
+ console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
318
+ }
319
+
320
+ // Update execution record as failed
321
+ if (executionRecord && store) {
322
+ executionRecord.complete(1);
323
+ try {
324
+ store.save(executionRecord);
325
+ } catch (storeErr) {
326
+ if (config && config.verbose) {
327
+ console.error(
328
+ `[Tracking] Warning: Could not update execution record: ${storeErr.message}`
329
+ );
330
+ }
331
+ }
332
+ }
333
+
334
+ // Call error callback
335
+ if (onError) {
336
+ onError(errorMessage, endTime, durationMs);
337
+ }
338
+ });
339
+
340
+ return child;
341
+ }
342
+
343
+ module.exports = {
344
+ runWithBunSpawn,
345
+ runWithNodeSpawn,
346
+ };