start-command 0.17.2 → 0.17.4

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,39 @@
1
1
  # start-command
2
2
 
3
+ ## 0.17.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 89d04d6: fix: Ensure execution status is updated when process is interrupted
8
+
9
+ This fix addresses Issue #60 where `$ --status` shows "executing" for finished commands.
10
+
11
+ Changes:
12
+ - Added signal handlers (SIGINT, SIGTERM, SIGHUP) to update execution status when process is interrupted
13
+ - Added `--cleanup` and `--cleanup-dry-run` flags to clean up stale records from crashed/killed processes
14
+ - Added `cleanupStale()` method to ExecutionStore to detect and clean stale records
15
+
16
+ The cleanup logic detects stale records by:
17
+ 1. Checking if the PID is still running (if on same platform)
18
+ 2. Checking if the record has exceeded max age (default: 24 hours)
19
+
20
+ Stale records are marked as "executed" with exit code -1 to indicate abnormal termination.
21
+
22
+ ## 0.17.3
23
+
24
+ ### Patch Changes
25
+
26
+ - a61f1a9: fix: Use Bun.spawn for reliable stdout capture on macOS (Issue #57)
27
+
28
+ 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`.
29
+
30
+ 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.
31
+ - Use `Bun.spawn` instead of `node:child_process` when running on Bun
32
+ - Use async stream readers with `getReader()` for real-time output display
33
+ - Use `await proc.exited` to ensure process completion before exiting
34
+ - Fall back to `node:child_process` with `close` event for Node.js compatibility
35
+ - Add verbose logging with `--verbose` flag for debugging
36
+
3
37
  ## 0.17.2
4
38
 
5
39
  ### 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.4",
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 = {
@@ -73,6 +73,10 @@ const config = {
73
73
  // Global execution store instance (initialized lazily)
74
74
  let executionStore = null;
75
75
 
76
+ // Global reference to current execution record for cleanup on signals
77
+ // This allows us to mark the execution as completed if the process is interrupted
78
+ let currentExecutionRecord = null;
79
+
76
80
  /**
77
81
  * Get the execution store instance
78
82
  * @returns {ExecutionStore}
@@ -87,6 +91,57 @@ function getExecutionStore() {
87
91
  return executionStore;
88
92
  }
89
93
 
94
+ /**
95
+ * Cleanup handler for signals and process exit
96
+ * Marks the current execution as completed with an appropriate exit code
97
+ * @param {string} signal - The signal that triggered the cleanup (e.g., 'SIGINT', 'SIGTERM')
98
+ * @param {number} exitCode - The exit code to use
99
+ */
100
+ function cleanupExecution(signal, exitCode) {
101
+ if (currentExecutionRecord && executionStore) {
102
+ // Signal-based exit codes:
103
+ // SIGINT (Ctrl+C): 130 (128 + 2)
104
+ // SIGTERM: 143 (128 + 15)
105
+ // SIGHUP: 129 (128 + 1)
106
+ currentExecutionRecord.complete(exitCode);
107
+ try {
108
+ executionStore.save(currentExecutionRecord);
109
+ if (config.verbose) {
110
+ console.error(
111
+ `\n[Tracking] Execution ${currentExecutionRecord.uuid} marked as completed (${signal}, exit code: ${exitCode})`
112
+ );
113
+ }
114
+ } catch (err) {
115
+ if (config.verbose) {
116
+ console.error(
117
+ `[Tracking] Warning: Could not save execution record on ${signal}: ${err.message}`
118
+ );
119
+ }
120
+ }
121
+ // Clear the reference to prevent double cleanup
122
+ currentExecutionRecord = null;
123
+ }
124
+ }
125
+
126
+ // Set up signal handlers to ensure execution status is updated on interruption
127
+ // SIGINT (Ctrl+C) - exit code 130 (128 + signal number 2)
128
+ process.on('SIGINT', () => {
129
+ cleanupExecution('SIGINT', 130);
130
+ process.exit(130);
131
+ });
132
+
133
+ // SIGTERM (kill command) - exit code 143 (128 + signal number 15)
134
+ process.on('SIGTERM', () => {
135
+ cleanupExecution('SIGTERM', 143);
136
+ process.exit(143);
137
+ });
138
+
139
+ // SIGHUP (terminal closed) - exit code 129 (128 + signal number 1)
140
+ process.on('SIGHUP', () => {
141
+ cleanupExecution('SIGHUP', 129);
142
+ process.exit(129);
143
+ });
144
+
90
145
  // Get all arguments passed after the command
91
146
  const args = process.argv.slice(2);
92
147
 
@@ -138,6 +193,12 @@ if (wrapperOptions.status) {
138
193
  process.exit(0);
139
194
  }
140
195
 
196
+ // Handle --cleanup flag
197
+ if (wrapperOptions.cleanup) {
198
+ handleCleanup(wrapperOptions.cleanupDryRun);
199
+ process.exit(0);
200
+ }
201
+
141
202
  // Check if no command was provided
142
203
  if (!parsedCommand || parsedCommand.trim() === '') {
143
204
  console.error('Error: No command provided');
@@ -206,6 +267,53 @@ function handleStatusQuery(uuid, outputFormat) {
206
267
  }
207
268
  }
208
269
 
270
+ /**
271
+ * Handle --cleanup flag
272
+ * Cleans up stale "executing" records (processes that crashed or were killed)
273
+ * @param {boolean} dryRun - If true, just report what would be cleaned
274
+ */
275
+ function handleCleanup(dryRun) {
276
+ const store = getExecutionStore();
277
+ if (!store) {
278
+ console.error('Error: Execution tracking is disabled.');
279
+ process.exit(1);
280
+ }
281
+
282
+ const result = store.cleanupStale({ dryRun });
283
+
284
+ if (result.errors.length > 0) {
285
+ for (const error of result.errors) {
286
+ console.error(`Error: ${error}`);
287
+ }
288
+ }
289
+
290
+ if (result.records.length === 0) {
291
+ console.log('No stale records found.');
292
+ return;
293
+ }
294
+
295
+ if (dryRun) {
296
+ console.log(
297
+ `Found ${result.records.length} stale record(s) that would be cleaned up:\n`
298
+ );
299
+ } else {
300
+ console.log(`Cleaned up ${result.cleaned} stale record(s):\n`);
301
+ }
302
+
303
+ for (const record of result.records) {
304
+ const startTime = new Date(record.startTime).toLocaleString();
305
+ console.log(` UUID: ${record.uuid}`);
306
+ console.log(` Command: ${record.command}`);
307
+ console.log(` Started: ${startTime}`);
308
+ console.log(` PID: ${record.pid || 'N/A'}`);
309
+ console.log('');
310
+ }
311
+
312
+ if (dryRun) {
313
+ console.log('Run with --cleanup to actually clean up these records.');
314
+ }
315
+ }
316
+
209
317
  /** Print usage information */
210
318
  function printUsage() {
211
319
  console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>]
@@ -225,6 +333,8 @@ Options:
225
333
  --auto-remove-docker-container Auto-remove docker container after exit
226
334
  --use-command-stream Use command-stream library for execution (experimental)
227
335
  --status <uuid> Show status of execution by UUID (--output-format: links-notation|json|text)
336
+ --cleanup Clean up stale "executing" records (crashed/killed processes)
337
+ --cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
228
338
  --version, -v Show version information
229
339
 
230
340
  Examples:
@@ -411,8 +521,9 @@ async function runWithIsolation(
411
521
  );
412
522
  console.log('');
413
523
 
414
- // Save initial execution record
524
+ // Save initial execution record and set global reference for signal cleanup
415
525
  if (executionRecord && store) {
526
+ currentExecutionRecord = executionRecord;
416
527
  try {
417
528
  store.save(executionRecord);
418
529
  } catch (err) {
@@ -478,7 +589,7 @@ async function runWithIsolation(
478
589
  // Write log file
479
590
  writeLogFile(logFilePath, logContent);
480
591
 
481
- // Update execution record as completed
592
+ // Update execution record as completed and clear global reference
482
593
  if (executionRecord && store) {
483
594
  executionRecord.complete(exitCode);
484
595
  try {
@@ -490,6 +601,8 @@ async function runWithIsolation(
490
601
  );
491
602
  }
492
603
  }
604
+ // Clear global reference since we've completed normally
605
+ currentExecutionRecord = null;
493
606
  }
494
607
 
495
608
  // Cleanup: delete the created user if we created one (unless --keep-user)
@@ -529,11 +642,18 @@ async function runWithIsolation(
529
642
  }
530
643
 
531
644
  /**
532
- * Run command directly (without isolation) - original synchronous version
645
+ * Run command directly (without isolation)
646
+ *
647
+ * Uses Bun.spawn when running on Bun for reliable event handling on macOS.
648
+ * Falls back to node:child_process for Node.js compatibility.
649
+ *
650
+ * Issue #57: On macOS with Bun, node:child_process events may not fire reliably
651
+ * before the event loop exits. Bun.spawn provides more reliable stream handling.
652
+ *
533
653
  * @param {string} cmd - Command to execute
534
654
  * @param {string} sessionId - Session UUID for tracking
535
655
  */
536
- function runDirect(cmd, sessionId) {
656
+ async function runDirect(cmd, sessionId) {
537
657
  // Get the command name (first word of the actual command to execute)
538
658
  const commandName = cmd.split(' ')[0];
539
659
 
@@ -573,6 +693,8 @@ function runDirect(cmd, sessionId) {
573
693
  runtimeVersion,
574
694
  },
575
695
  });
696
+ // Set global reference for signal cleanup
697
+ currentExecutionRecord = executionRecord;
576
698
  }
577
699
 
578
700
  // Log header
@@ -606,76 +728,10 @@ function runDirect(cmd, sessionId) {
606
728
  );
607
729
  console.log('');
608
730
 
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;
731
+ // Completion callback
732
+ const onComplete = (exitCode, endTime, _logContent, durationMs) => {
733
+ // Clear global reference since execution completed normally
734
+ currentExecutionRecord = null;
679
735
  console.log('');
680
736
  console.log(
681
737
  createFinishBlock({
@@ -686,47 +742,16 @@ function runDirect(cmd, sessionId) {
686
742
  durationMs,
687
743
  })
688
744
  );
689
-
690
- // If command failed, try to auto-report
691
745
  if (exitCode !== 0) {
692
746
  handleFailure(config, commandName, cmd, exitCode, logFilePath);
693
747
  }
694
-
695
748
  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
- }
749
+ };
729
750
 
751
+ // Error callback
752
+ const onError = (errorMessage, endTime, durationMs) => {
753
+ // Clear global reference since execution completed (with error)
754
+ currentExecutionRecord = null;
730
755
  console.error(`\n${errorMessage}`);
731
756
  console.log('');
732
757
  console.log(
@@ -738,11 +763,30 @@ function runDirect(cmd, sessionId) {
738
763
  durationMs,
739
764
  })
740
765
  );
741
-
742
766
  handleFailure(config, commandName, cmd, 1, logFilePath);
743
-
744
767
  process.exit(1);
745
- });
768
+ };
769
+
770
+ // Use Bun.spawn when running on Bun for reliable event handling on macOS
771
+ // Fall back to node:child_process for Node.js compatibility
772
+ const spawnOptions = {
773
+ shell,
774
+ shellArgs,
775
+ logFilePath,
776
+ logContent,
777
+ startTimeMs,
778
+ executionRecord,
779
+ store,
780
+ config,
781
+ onComplete,
782
+ onError,
783
+ };
784
+
785
+ if (typeof Bun !== 'undefined') {
786
+ await runWithBunSpawn(spawnOptions);
787
+ } else {
788
+ runWithNodeSpawn(spawnOptions);
789
+ }
746
790
  }
747
791
 
748
792
  /**
@@ -801,6 +845,8 @@ async function runDirectWithCommandStream(
801
845
  executionMode: 'command-stream',
802
846
  },
803
847
  });
848
+ // Set global reference for signal cleanup
849
+ currentExecutionRecord = executionRecord;
804
850
  }
805
851
 
806
852
  // Log header
@@ -892,7 +938,7 @@ async function runDirectWithCommandStream(
892
938
  console.error(`\nWarning: Could not save log file: ${err.message}`);
893
939
  }
894
940
 
895
- // Update execution record as completed
941
+ // Update execution record as completed and clear global reference
896
942
  if (executionRecord && store) {
897
943
  executionRecord.complete(exitCode);
898
944
  try {
@@ -904,6 +950,8 @@ async function runDirectWithCommandStream(
904
950
  );
905
951
  }
906
952
  }
953
+ // Clear global reference since we've completed normally
954
+ currentExecutionRecord = null;
907
955
  }
908
956
 
909
957
  // Print finish block
@@ -19,6 +19,8 @@
19
19
  * --use-command-stream Use command-stream library for command execution (experimental)
20
20
  * --status <uuid> Show status of a previous command execution by UUID
21
21
  * --output-format <format> Output format for status (links-notation, json, text)
22
+ * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
23
+ * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
22
24
  */
23
25
 
24
26
  // Debug mode from environment
@@ -91,6 +93,8 @@ function parseArgs(args) {
91
93
  useCommandStream: false, // Use command-stream library for command execution
92
94
  status: null, // UUID to show status for
93
95
  outputFormat: null, // Output format for status (links-notation, json, text)
96
+ cleanup: false, // Clean up stale "executing" records
97
+ cleanupDryRun: false, // Show what would be cleaned without actually cleaning
94
98
  };
95
99
 
96
100
  let commandArgs = [];
@@ -339,6 +343,19 @@ function parseOption(args, index, options) {
339
343
  return 1;
340
344
  }
341
345
 
346
+ // --cleanup
347
+ if (arg === '--cleanup') {
348
+ options.cleanup = true;
349
+ return 1;
350
+ }
351
+
352
+ // --cleanup-dry-run
353
+ if (arg === '--cleanup-dry-run') {
354
+ options.cleanup = true;
355
+ options.cleanupDryRun = true;
356
+ return 1;
357
+ }
358
+
342
359
  // Not a recognized wrapper option
343
360
  return 0;
344
361
  }
@@ -517,6 +517,112 @@ class ExecutionStore {
517
517
  return records.slice(0, limit);
518
518
  }
519
519
 
520
+ /**
521
+ * Clean up stale "executing" records
522
+ *
523
+ * Stale records are those that:
524
+ * 1. Have status "executing"
525
+ * 2. Either:
526
+ * - Their process (by PID) is no longer running (on same hostname)
527
+ * - They have been "executing" for longer than maxAgeMs (default: 24 hours)
528
+ *
529
+ * @param {object} options - Cleanup options
530
+ * @param {number} options.maxAgeMs - Max age for executing records (default: 24 hours)
531
+ * @param {boolean} options.dryRun - If true, just report what would be cleaned (default: false)
532
+ * @returns {{cleaned: number, records: ExecutionRecord[], errors: string[]}}
533
+ */
534
+ cleanupStale(options = {}) {
535
+ const maxAgeMs = options.maxAgeMs || 24 * 60 * 60 * 1000; // 24 hours
536
+ const dryRun = options.dryRun || false;
537
+
538
+ const result = {
539
+ cleaned: 0,
540
+ records: [],
541
+ errors: [],
542
+ };
543
+
544
+ const records = this.readLinoRecords();
545
+ const executingRecords = records.filter(
546
+ (r) => r.status === ExecutionStatus.EXECUTING
547
+ );
548
+
549
+ const staleRecords = [];
550
+
551
+ for (const record of executingRecords) {
552
+ let isStale = false;
553
+ let reason = '';
554
+
555
+ // Check if the process is still running (only if on same platform)
556
+ if (record.pid && record.platform === process.platform) {
557
+ try {
558
+ // Signal 0 just checks if process exists
559
+ process.kill(record.pid, 0);
560
+ // Process exists, check age
561
+ } catch {
562
+ // Process doesn't exist - record is stale
563
+ isStale = true;
564
+ reason = `process ${record.pid} no longer running`;
565
+ }
566
+ }
567
+
568
+ // Check age if not already determined to be stale
569
+ if (!isStale) {
570
+ const startTime = new Date(record.startTime).getTime();
571
+ const age = Date.now() - startTime;
572
+ if (age > maxAgeMs) {
573
+ isStale = true;
574
+ reason = `running for ${Math.round(age / 1000 / 60)} minutes (max: ${Math.round(maxAgeMs / 1000 / 60)} minutes)`;
575
+ }
576
+ }
577
+
578
+ if (isStale) {
579
+ this.log(`Stale record found: ${record.uuid} (${reason})`);
580
+ staleRecords.push(record);
581
+ }
582
+ }
583
+
584
+ result.records = staleRecords;
585
+
586
+ if (!dryRun && staleRecords.length > 0) {
587
+ const lock = new LockManager(this.lockFilePath);
588
+
589
+ if (!lock.acquire()) {
590
+ result.errors.push('Failed to acquire lock for cleanup');
591
+ return result;
592
+ }
593
+
594
+ try {
595
+ // Re-read records to ensure consistency
596
+ const currentRecords = this.readLinoRecords();
597
+
598
+ // Update stale records to "executed" status with exit code -1 (abnormal termination)
599
+ for (const staleRecord of staleRecords) {
600
+ const index = currentRecords.findIndex(
601
+ (r) => r.uuid === staleRecord.uuid
602
+ );
603
+ if (index >= 0) {
604
+ // Mark as executed with exit code -1 to indicate abnormal termination
605
+ currentRecords[index].status = ExecutionStatus.EXECUTED;
606
+ currentRecords[index].exitCode = -1;
607
+ currentRecords[index].endTime = new Date().toISOString();
608
+ result.cleaned++;
609
+ }
610
+ }
611
+
612
+ this.writeLinoRecords(currentRecords);
613
+ this.log(`Cleaned up ${result.cleaned} stale records`);
614
+ } catch (err) {
615
+ result.errors.push(`Cleanup error: ${err.message}`);
616
+ } finally {
617
+ lock.release();
618
+ }
619
+ } else if (dryRun) {
620
+ result.cleaned = staleRecords.length;
621
+ }
622
+
623
+ return result;
624
+ }
625
+
520
626
  /**
521
627
  * Delete an execution record
522
628
  * @param {string} uuid
@@ -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
+ };
@@ -889,3 +889,25 @@ describe('VALID_OUTPUT_FORMATS', () => {
889
889
  assert.ok(VALID_OUTPUT_FORMATS.includes('text'));
890
890
  });
891
891
  });
892
+
893
+ describe('cleanup options', () => {
894
+ it('should parse --cleanup flag', () => {
895
+ const result = parseArgs(['--cleanup']);
896
+ assert.strictEqual(result.wrapperOptions.cleanup, true);
897
+ assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
898
+ assert.strictEqual(result.command, '');
899
+ });
900
+
901
+ it('should parse --cleanup-dry-run flag', () => {
902
+ const result = parseArgs(['--cleanup-dry-run']);
903
+ assert.strictEqual(result.wrapperOptions.cleanup, true);
904
+ assert.strictEqual(result.wrapperOptions.cleanupDryRun, true);
905
+ assert.strictEqual(result.command, '');
906
+ });
907
+
908
+ it('should default cleanup to false', () => {
909
+ const result = parseArgs(['echo', 'hello']);
910
+ assert.strictEqual(result.wrapperOptions.cleanup, false);
911
+ assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
912
+ });
913
+ });
@@ -480,4 +480,125 @@ describe('ExecutionStore verifyConsistency', () => {
480
480
  });
481
481
  });
482
482
 
483
+ describe('ExecutionStore cleanupStale', () => {
484
+ let store;
485
+
486
+ beforeEach(() => {
487
+ cleanupTestDir();
488
+ store = new ExecutionStore({
489
+ appFolder: TEST_APP_FOLDER,
490
+ useLinks: false,
491
+ });
492
+ });
493
+
494
+ afterEach(() => {
495
+ cleanupTestDir();
496
+ });
497
+
498
+ it('should return empty result when no stale records exist', () => {
499
+ // Create a completed record
500
+ const record = new ExecutionRecord({
501
+ command: 'echo 1',
502
+ pid: process.pid, // Current process
503
+ });
504
+ record.complete(0);
505
+ store.save(record);
506
+
507
+ const result = store.cleanupStale();
508
+ expect(result.cleaned).toBe(0);
509
+ expect(result.records.length).toBe(0);
510
+ expect(result.errors.length).toBe(0);
511
+ });
512
+
513
+ it('should detect stale records with non-existent PIDs', () => {
514
+ // Create an "executing" record with a non-existent PID
515
+ const record = new ExecutionRecord({
516
+ command: 'echo stale',
517
+ pid: 999999999, // Non-existent PID
518
+ platform: process.platform,
519
+ });
520
+ store.save(record);
521
+
522
+ const result = store.cleanupStale({ dryRun: true });
523
+ expect(result.records.length).toBe(1);
524
+ expect(result.records[0].uuid).toBe(record.uuid);
525
+ expect(result.cleaned).toBe(1);
526
+ });
527
+
528
+ it('should detect stale records that exceed max age', () => {
529
+ // Create an "executing" record with an old start time
530
+ const oldTime = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); // 25 hours ago
531
+ const record = new ExecutionRecord({
532
+ command: 'echo old',
533
+ pid: process.pid, // Current process (still running)
534
+ startTime: oldTime,
535
+ platform: process.platform,
536
+ });
537
+ store.save(record);
538
+
539
+ // Use default max age (24 hours)
540
+ const result = store.cleanupStale({ dryRun: true });
541
+ expect(result.records.length).toBe(1);
542
+ expect(result.records[0].uuid).toBe(record.uuid);
543
+ });
544
+
545
+ it('should not detect records within max age as stale', () => {
546
+ // Create an "executing" record with a recent start time
547
+ const record = new ExecutionRecord({
548
+ command: 'echo recent',
549
+ pid: process.pid, // Current process (still running)
550
+ platform: process.platform,
551
+ });
552
+ store.save(record);
553
+
554
+ const result = store.cleanupStale({ dryRun: true });
555
+ // Should not find this as stale because process is running and not too old
556
+ expect(result.records.length).toBe(0);
557
+ });
558
+
559
+ it('should clean up stale records when dryRun is false', () => {
560
+ // Create an "executing" record with a non-existent PID
561
+ const record = new ExecutionRecord({
562
+ command: 'echo cleanup-me',
563
+ pid: 999999999, // Non-existent PID
564
+ platform: process.platform,
565
+ });
566
+ store.save(record);
567
+
568
+ // Verify it's still "executing"
569
+ let retrieved = store.get(record.uuid);
570
+ expect(retrieved.status).toBe(ExecutionStatus.EXECUTING);
571
+
572
+ // Clean it up
573
+ const result = store.cleanupStale({ dryRun: false });
574
+ expect(result.cleaned).toBe(1);
575
+
576
+ // Verify it's now "executed" with exit code -1
577
+ retrieved = store.get(record.uuid);
578
+ expect(retrieved.status).toBe(ExecutionStatus.EXECUTED);
579
+ expect(retrieved.exitCode).toBe(-1);
580
+ expect(retrieved.endTime).toBeTruthy();
581
+ });
582
+
583
+ it('should handle custom max age', () => {
584
+ // Create an "executing" record with start time 5 minutes ago
585
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
586
+ const record = new ExecutionRecord({
587
+ command: 'echo custom-age',
588
+ pid: process.pid, // Current process (still running)
589
+ startTime: fiveMinutesAgo,
590
+ platform: process.platform,
591
+ });
592
+ store.save(record);
593
+
594
+ // Should not find as stale with default 24h max age
595
+ let result = store.cleanupStale({ dryRun: true });
596
+ expect(result.records.length).toBe(0);
597
+
598
+ // Should find as stale with 1 minute max age
599
+ result = store.cleanupStale({ dryRun: true, maxAgeMs: 60 * 1000 });
600
+ expect(result.records.length).toBe(1);
601
+ });
602
+ });
603
+
483
604
  console.log('=== Execution Store Unit Tests ===');