command-stream 0.6.0 → 0.7.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.
- package/package.json +1 -1
- package/src/$.mjs +560 -18
- package/src/commands/$.cd.mjs +3 -2
- package/src/commands/$.pwd.mjs +1 -1
- package/src/shell-parser.mjs +375 -0
package/package.json
CHANGED
package/src/$.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import cp from 'child_process';
|
|
9
9
|
import path from 'path';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import { parseShellCommand, needsRealShell } from './shell-parser.mjs';
|
|
10
12
|
|
|
11
13
|
const isBun = typeof globalThis.Bun !== 'undefined';
|
|
12
14
|
|
|
@@ -21,6 +23,73 @@ function trace(category, messageOrFunc) {
|
|
|
21
23
|
console.error(`[TRACE ${timestamp}] [${category}] ${message}`);
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
// Shell detection cache
|
|
27
|
+
let cachedShell = null;
|
|
28
|
+
|
|
29
|
+
// Save initial working directory for restoration
|
|
30
|
+
const initialWorkingDirectory = process.cwd();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Find an available shell by checking multiple options in order
|
|
34
|
+
* Returns the shell command and arguments to use
|
|
35
|
+
*/
|
|
36
|
+
function findAvailableShell() {
|
|
37
|
+
if (cachedShell) {
|
|
38
|
+
trace('ShellDetection', () => `Using cached shell: ${cachedShell.cmd}`);
|
|
39
|
+
return cachedShell;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const shellsToTry = [
|
|
43
|
+
// Try absolute paths first (most reliable)
|
|
44
|
+
{ cmd: '/bin/sh', args: ['-l', '-c'], checkPath: true },
|
|
45
|
+
{ cmd: '/usr/bin/sh', args: ['-l', '-c'], checkPath: true },
|
|
46
|
+
{ cmd: '/bin/bash', args: ['-l', '-c'], checkPath: true },
|
|
47
|
+
{ cmd: '/usr/bin/bash', args: ['-l', '-c'], checkPath: true },
|
|
48
|
+
{ cmd: '/bin/zsh', args: ['-l', '-c'], checkPath: true },
|
|
49
|
+
{ cmd: '/usr/bin/zsh', args: ['-l', '-c'], checkPath: true },
|
|
50
|
+
// macOS specific paths
|
|
51
|
+
{ cmd: '/usr/local/bin/bash', args: ['-l', '-c'], checkPath: true },
|
|
52
|
+
{ cmd: '/usr/local/bin/zsh', args: ['-l', '-c'], checkPath: true },
|
|
53
|
+
// Linux brew paths
|
|
54
|
+
{ cmd: '/home/linuxbrew/.linuxbrew/bin/bash', args: ['-l', '-c'], checkPath: true },
|
|
55
|
+
{ cmd: '/home/linuxbrew/.linuxbrew/bin/zsh', args: ['-l', '-c'], checkPath: true },
|
|
56
|
+
// Try shells in PATH as fallback (which might not work in all environments)
|
|
57
|
+
// Using separate -l and -c flags for better compatibility
|
|
58
|
+
{ cmd: 'sh', args: ['-l', '-c'], checkPath: false },
|
|
59
|
+
{ cmd: 'bash', args: ['-l', '-c'], checkPath: false },
|
|
60
|
+
{ cmd: 'zsh', args: ['-l', '-c'], checkPath: false }
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
for (const shell of shellsToTry) {
|
|
64
|
+
try {
|
|
65
|
+
if (shell.checkPath) {
|
|
66
|
+
// Check if the absolute path exists
|
|
67
|
+
if (fs.existsSync(shell.cmd)) {
|
|
68
|
+
trace('ShellDetection', () => `Found shell at absolute path: ${shell.cmd}`);
|
|
69
|
+
cachedShell = { cmd: shell.cmd, args: shell.args };
|
|
70
|
+
return cachedShell;
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// Try to execute 'which' to check if command is in PATH
|
|
74
|
+
const result = cp.spawnSync('which', [shell.cmd], { encoding: 'utf-8' });
|
|
75
|
+
if (result.status === 0 && result.stdout) {
|
|
76
|
+
const shellPath = result.stdout.trim();
|
|
77
|
+
trace('ShellDetection', () => `Found shell in PATH: ${shell.cmd} => ${shellPath}`);
|
|
78
|
+
cachedShell = { cmd: shell.cmd, args: shell.args };
|
|
79
|
+
return cachedShell;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// Continue to next shell option
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Final fallback - use absolute path to sh
|
|
88
|
+
trace('ShellDetection', () => 'WARNING: No shell found, using /bin/sh as fallback');
|
|
89
|
+
cachedShell = { cmd: '/bin/sh', args: ['-l', '-c'] };
|
|
90
|
+
return cachedShell;
|
|
91
|
+
}
|
|
92
|
+
|
|
24
93
|
|
|
25
94
|
|
|
26
95
|
// Track parent stream state for graceful shutdown
|
|
@@ -211,13 +280,127 @@ function forceCleanupAll() {
|
|
|
211
280
|
// Clear activeProcessRunners
|
|
212
281
|
activeProcessRunners.clear();
|
|
213
282
|
|
|
214
|
-
// Reset flags
|
|
283
|
+
// Reset signal handler flags
|
|
215
284
|
sigintHandlerInstalled = false;
|
|
216
285
|
sigintHandler = null;
|
|
217
286
|
|
|
218
287
|
trace('SignalHandler', () => `Force cleanup completed - removed ${commandStreamListeners.length} handlers`);
|
|
219
288
|
}
|
|
220
289
|
|
|
290
|
+
// Complete global state reset for testing - clears all library state
|
|
291
|
+
function resetGlobalState() {
|
|
292
|
+
// CRITICAL: Restore working directory first before anything else
|
|
293
|
+
// This MUST succeed or tests will fail with spawn errors
|
|
294
|
+
try {
|
|
295
|
+
// Try to get current directory - this might fail if we're in a deleted directory
|
|
296
|
+
let currentDir;
|
|
297
|
+
try {
|
|
298
|
+
currentDir = process.cwd();
|
|
299
|
+
} catch (e) {
|
|
300
|
+
// Can't even get cwd, we're in a deleted directory
|
|
301
|
+
currentDir = null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Always try to restore to initial directory
|
|
305
|
+
if (!currentDir || currentDir !== initialWorkingDirectory) {
|
|
306
|
+
// Check if initial directory still exists
|
|
307
|
+
if (fs.existsSync(initialWorkingDirectory)) {
|
|
308
|
+
process.chdir(initialWorkingDirectory);
|
|
309
|
+
trace('GlobalState', () => `Restored working directory from ${currentDir} to ${initialWorkingDirectory}`);
|
|
310
|
+
} else {
|
|
311
|
+
// Initial directory is gone, use fallback
|
|
312
|
+
const fallback = process.env.HOME || '/workspace/command-stream' || '/';
|
|
313
|
+
if (fs.existsSync(fallback)) {
|
|
314
|
+
process.chdir(fallback);
|
|
315
|
+
trace('GlobalState', () => `Initial directory gone, changed to fallback: ${fallback}`);
|
|
316
|
+
} else {
|
|
317
|
+
// Last resort - try root
|
|
318
|
+
process.chdir('/');
|
|
319
|
+
trace('GlobalState', () => `Emergency fallback to root directory`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
trace('GlobalState', () => `Critical error restoring working directory: ${e.message}`);
|
|
325
|
+
// This is critical - we MUST have a valid working directory
|
|
326
|
+
try {
|
|
327
|
+
// Try home directory
|
|
328
|
+
if (process.env.HOME && fs.existsSync(process.env.HOME)) {
|
|
329
|
+
process.chdir(process.env.HOME);
|
|
330
|
+
} else {
|
|
331
|
+
// Last resort - root
|
|
332
|
+
process.chdir('/');
|
|
333
|
+
}
|
|
334
|
+
} catch (e2) {
|
|
335
|
+
console.error('FATAL: Cannot set any working directory!', e2);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// First, properly clean up all active ProcessRunners
|
|
340
|
+
for (const runner of activeProcessRunners) {
|
|
341
|
+
if (runner) {
|
|
342
|
+
try {
|
|
343
|
+
// If the runner was never started, clean it up
|
|
344
|
+
if (!runner.started) {
|
|
345
|
+
trace('resetGlobalState', () => `Cleaning up unstarted ProcessRunner: ${runner.spec?.command?.slice(0, 50)}`);
|
|
346
|
+
// Call the cleanup method to properly release resources
|
|
347
|
+
if (runner._cleanup) {
|
|
348
|
+
runner._cleanup();
|
|
349
|
+
}
|
|
350
|
+
} else if (runner.kill) {
|
|
351
|
+
// For started runners, kill them
|
|
352
|
+
runner.kill();
|
|
353
|
+
}
|
|
354
|
+
} catch (e) {
|
|
355
|
+
// Ignore errors
|
|
356
|
+
trace('resetGlobalState', () => `Error during cleanup: ${e.message}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Call existing cleanup
|
|
362
|
+
forceCleanupAll();
|
|
363
|
+
|
|
364
|
+
// Clear shell cache to force re-detection with our fixed logic
|
|
365
|
+
cachedShell = null;
|
|
366
|
+
|
|
367
|
+
// Reset parent stream monitoring
|
|
368
|
+
parentStreamsMonitored = false;
|
|
369
|
+
|
|
370
|
+
// Reset shell settings to defaults
|
|
371
|
+
globalShellSettings = {
|
|
372
|
+
xtrace: false,
|
|
373
|
+
errexit: false,
|
|
374
|
+
pipefail: false,
|
|
375
|
+
verbose: false,
|
|
376
|
+
noglob: false,
|
|
377
|
+
allexport: false
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Don't clear virtual commands - they should persist across tests
|
|
381
|
+
// Just make sure they're enabled
|
|
382
|
+
virtualCommandsEnabled = true;
|
|
383
|
+
|
|
384
|
+
// Reset ANSI config to defaults
|
|
385
|
+
globalAnsiConfig = {
|
|
386
|
+
forceColor: false,
|
|
387
|
+
noColor: false
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Make sure built-in virtual commands are registered
|
|
391
|
+
if (virtualCommands.size === 0) {
|
|
392
|
+
// Re-import to re-register commands (synchronously if possible)
|
|
393
|
+
trace('GlobalState', () => 'Re-registering virtual commands');
|
|
394
|
+
import('./commands/index.mjs').then(() => {
|
|
395
|
+
trace('GlobalState', () => `Virtual commands re-registered, count: ${virtualCommands.size}`);
|
|
396
|
+
}).catch((e) => {
|
|
397
|
+
trace('GlobalState', () => `Error re-registering virtual commands: ${e.message}`);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
trace('GlobalState', () => 'Global state reset completed');
|
|
402
|
+
}
|
|
403
|
+
|
|
221
404
|
function monitorParentStreams() {
|
|
222
405
|
if (parentStreamsMonitored) {
|
|
223
406
|
trace('StreamMonitor', () => 'Parent streams already monitored, skipping');
|
|
@@ -478,6 +661,12 @@ class StreamEmitter {
|
|
|
478
661
|
}
|
|
479
662
|
|
|
480
663
|
on(event, listener) {
|
|
664
|
+
trace('StreamEmitter', () => `on() called | ${JSON.stringify({
|
|
665
|
+
event,
|
|
666
|
+
hasExistingListeners: this.listeners.has(event),
|
|
667
|
+
listenerCount: this.listeners.get(event)?.length || 0
|
|
668
|
+
})}`);
|
|
669
|
+
|
|
481
670
|
if (!this.listeners.has(event)) {
|
|
482
671
|
this.listeners.set(event, []);
|
|
483
672
|
}
|
|
@@ -489,6 +678,7 @@ class StreamEmitter {
|
|
|
489
678
|
}
|
|
490
679
|
|
|
491
680
|
once(event, listener) {
|
|
681
|
+
trace('StreamEmitter', () => `once() called for event: ${event}`);
|
|
492
682
|
const onceWrapper = (...args) => {
|
|
493
683
|
this.off(event, onceWrapper);
|
|
494
684
|
listener(...args);
|
|
@@ -514,11 +704,18 @@ class StreamEmitter {
|
|
|
514
704
|
}
|
|
515
705
|
|
|
516
706
|
off(event, listener) {
|
|
707
|
+
trace('StreamEmitter', () => `off() called | ${JSON.stringify({
|
|
708
|
+
event,
|
|
709
|
+
hasListeners: !!this.listeners.get(event),
|
|
710
|
+
listenerCount: this.listeners.get(event)?.length || 0
|
|
711
|
+
})}`);
|
|
712
|
+
|
|
517
713
|
const eventListeners = this.listeners.get(event);
|
|
518
714
|
if (eventListeners) {
|
|
519
715
|
const index = eventListeners.indexOf(listener);
|
|
520
716
|
if (index !== -1) {
|
|
521
717
|
eventListeners.splice(index, 1);
|
|
718
|
+
trace('StreamEmitter', () => `Removed listener at index ${index}`);
|
|
522
719
|
}
|
|
523
720
|
}
|
|
524
721
|
return this;
|
|
@@ -569,6 +766,20 @@ function buildShellCommand(strings, values) {
|
|
|
569
766
|
valuesLength: values.length
|
|
570
767
|
}, null, 2)}`);
|
|
571
768
|
|
|
769
|
+
// Special case: if we have a single value with empty surrounding strings,
|
|
770
|
+
// and the value looks like a complete shell command, treat it as raw
|
|
771
|
+
if (values.length === 1 && strings.length === 2 &&
|
|
772
|
+
strings[0] === '' && strings[1] === '' &&
|
|
773
|
+
typeof values[0] === 'string') {
|
|
774
|
+
const commandStr = values[0];
|
|
775
|
+
// Check if this looks like a complete shell command (contains spaces and shell-safe characters)
|
|
776
|
+
const commandPattern = /^[a-zA-Z0-9_\-./=,+@:\s"'`$(){}<>|&;*?[\]~\\]+$/;
|
|
777
|
+
if (commandPattern.test(commandStr) && commandStr.trim().length > 0) {
|
|
778
|
+
trace('Utils', () => `BRANCH: buildShellCommand => COMPLETE_COMMAND | ${JSON.stringify({ command: commandStr }, null, 2)}`);
|
|
779
|
+
return commandStr;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
572
783
|
let out = '';
|
|
573
784
|
for (let i = 0; i < strings.length; i++) {
|
|
574
785
|
out += strings[i];
|
|
@@ -590,16 +801,28 @@ function buildShellCommand(strings, values) {
|
|
|
590
801
|
}
|
|
591
802
|
|
|
592
803
|
function asBuffer(chunk) {
|
|
593
|
-
if (Buffer.isBuffer(chunk))
|
|
594
|
-
|
|
804
|
+
if (Buffer.isBuffer(chunk)) {
|
|
805
|
+
trace('Utils', () => `asBuffer: Already a buffer, length: ${chunk.length}`);
|
|
806
|
+
return chunk;
|
|
807
|
+
}
|
|
808
|
+
if (typeof chunk === 'string') {
|
|
809
|
+
trace('Utils', () => `asBuffer: Converting string to buffer, length: ${chunk.length}`);
|
|
810
|
+
return Buffer.from(chunk);
|
|
811
|
+
}
|
|
812
|
+
trace('Utils', () => 'asBuffer: Converting unknown type to buffer');
|
|
595
813
|
return Buffer.from(chunk);
|
|
596
814
|
}
|
|
597
815
|
|
|
598
816
|
async function pumpReadable(readable, onChunk) {
|
|
599
|
-
if (!readable)
|
|
817
|
+
if (!readable) {
|
|
818
|
+
trace('Utils', () => 'pumpReadable: No readable stream provided');
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
trace('Utils', () => 'pumpReadable: Starting to pump readable stream');
|
|
600
822
|
for await (const chunk of readable) {
|
|
601
823
|
await onChunk(asBuffer(chunk));
|
|
602
824
|
}
|
|
825
|
+
trace('Utils', () => 'pumpReadable: Finished pumping readable stream');
|
|
603
826
|
}
|
|
604
827
|
|
|
605
828
|
// Enhanced process runner with streaming capabilities
|
|
@@ -620,6 +843,7 @@ class ProcessRunner extends StreamEmitter {
|
|
|
620
843
|
cwd: undefined,
|
|
621
844
|
env: undefined,
|
|
622
845
|
interactive: false, // Explicitly request TTY forwarding for interactive commands
|
|
846
|
+
shellOperators: true, // Enable shell operator parsing by default
|
|
623
847
|
...options
|
|
624
848
|
};
|
|
625
849
|
|
|
@@ -660,14 +884,26 @@ class ProcessRunner extends StreamEmitter {
|
|
|
660
884
|
|
|
661
885
|
// Stream property getters for child process streams (null for virtual commands)
|
|
662
886
|
get stdout() {
|
|
887
|
+
trace('ProcessRunner', () => `stdout getter accessed | ${JSON.stringify({
|
|
888
|
+
hasChild: !!this.child,
|
|
889
|
+
hasStdout: !!(this.child && this.child.stdout)
|
|
890
|
+
}, null, 2)}`);
|
|
663
891
|
return this.child ? this.child.stdout : null;
|
|
664
892
|
}
|
|
665
893
|
|
|
666
894
|
get stderr() {
|
|
895
|
+
trace('ProcessRunner', () => `stderr getter accessed | ${JSON.stringify({
|
|
896
|
+
hasChild: !!this.child,
|
|
897
|
+
hasStderr: !!(this.child && this.child.stderr)
|
|
898
|
+
}, null, 2)}`);
|
|
667
899
|
return this.child ? this.child.stderr : null;
|
|
668
900
|
}
|
|
669
901
|
|
|
670
902
|
get stdin() {
|
|
903
|
+
trace('ProcessRunner', () => `stdin getter accessed | ${JSON.stringify({
|
|
904
|
+
hasChild: !!this.child,
|
|
905
|
+
hasStdin: !!(this.child && this.child.stdin)
|
|
906
|
+
}, null, 2)}`);
|
|
671
907
|
return this.child ? this.child.stdin : null;
|
|
672
908
|
}
|
|
673
909
|
|
|
@@ -995,7 +1231,13 @@ class ProcessRunner extends StreamEmitter {
|
|
|
995
1231
|
}
|
|
996
1232
|
|
|
997
1233
|
async _forwardTTYStdin() {
|
|
1234
|
+
trace('ProcessRunner', () => `_forwardTTYStdin ENTER | ${JSON.stringify({
|
|
1235
|
+
isTTY: process.stdin.isTTY,
|
|
1236
|
+
hasChildStdin: !!this.child?.stdin
|
|
1237
|
+
}, null, 2)}`);
|
|
1238
|
+
|
|
998
1239
|
if (!process.stdin.isTTY || !this.child.stdin) {
|
|
1240
|
+
trace('ProcessRunner', () => 'TTY forwarding skipped - no TTY or no child stdin');
|
|
999
1241
|
return;
|
|
1000
1242
|
}
|
|
1001
1243
|
|
|
@@ -1048,6 +1290,7 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1048
1290
|
};
|
|
1049
1291
|
|
|
1050
1292
|
const cleanup = () => {
|
|
1293
|
+
trace('ProcessRunner', () => 'TTY stdin cleanup - restoring terminal mode');
|
|
1051
1294
|
process.stdin.removeListener('data', onData);
|
|
1052
1295
|
if (process.stdin.setRawMode) {
|
|
1053
1296
|
process.stdin.setRawMode(false);
|
|
@@ -1401,6 +1644,49 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1401
1644
|
if (this.spec.mode === 'shell') {
|
|
1402
1645
|
trace('ProcessRunner', () => `BRANCH: spec.mode => shell | ${JSON.stringify({}, null, 2)}`);
|
|
1403
1646
|
|
|
1647
|
+
// Check if shell operator parsing is enabled and command contains operators
|
|
1648
|
+
const hasShellOperators = this.spec.command.includes('&&') ||
|
|
1649
|
+
this.spec.command.includes('||') ||
|
|
1650
|
+
this.spec.command.includes('(') ||
|
|
1651
|
+
this.spec.command.includes(';') ||
|
|
1652
|
+
(this.spec.command.includes('cd ') && this.spec.command.includes('&&'));
|
|
1653
|
+
|
|
1654
|
+
// Intelligent detection: disable shell operators for streaming patterns
|
|
1655
|
+
const isStreamingPattern = this.spec.command.includes('sleep') && this.spec.command.includes(';') &&
|
|
1656
|
+
(this.spec.command.includes('echo') || this.spec.command.includes('printf'));
|
|
1657
|
+
|
|
1658
|
+
// Also check if we're in streaming mode (via .stream() method)
|
|
1659
|
+
const shouldUseShellOperators = this.options.shellOperators && hasShellOperators && !isStreamingPattern && !this._isStreaming;
|
|
1660
|
+
|
|
1661
|
+
trace('ProcessRunner', () => `Shell operator detection | ${JSON.stringify({
|
|
1662
|
+
hasShellOperators,
|
|
1663
|
+
shellOperatorsEnabled: this.options.shellOperators,
|
|
1664
|
+
isStreamingPattern,
|
|
1665
|
+
isStreaming: this._isStreaming,
|
|
1666
|
+
shouldUseShellOperators,
|
|
1667
|
+
command: this.spec.command.slice(0, 100)
|
|
1668
|
+
}, null, 2)}`);
|
|
1669
|
+
|
|
1670
|
+
// Only use enhanced parser when appropriate
|
|
1671
|
+
if (!this.options._bypassVirtual && shouldUseShellOperators && !needsRealShell(this.spec.command)) {
|
|
1672
|
+
const enhancedParsed = parseShellCommand(this.spec.command);
|
|
1673
|
+
if (enhancedParsed && enhancedParsed.type !== 'simple') {
|
|
1674
|
+
trace('ProcessRunner', () => `Using enhanced parser for shell operators | ${JSON.stringify({
|
|
1675
|
+
type: enhancedParsed.type,
|
|
1676
|
+
command: this.spec.command.slice(0, 50)
|
|
1677
|
+
}, null, 2)}`);
|
|
1678
|
+
|
|
1679
|
+
if (enhancedParsed.type === 'sequence') {
|
|
1680
|
+
return await this._runSequence(enhancedParsed);
|
|
1681
|
+
} else if (enhancedParsed.type === 'subshell') {
|
|
1682
|
+
return await this._runSubshell(enhancedParsed);
|
|
1683
|
+
} else if (enhancedParsed.type === 'pipeline') {
|
|
1684
|
+
return await this._runPipeline(enhancedParsed.commands);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// Fallback to original simple parser
|
|
1404
1690
|
const parsed = this._parseCommand(this.spec.command);
|
|
1405
1691
|
trace('ProcessRunner', () => `Parsed command | ${JSON.stringify({
|
|
1406
1692
|
type: parsed?.type,
|
|
@@ -1448,7 +1734,8 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1448
1734
|
}
|
|
1449
1735
|
}
|
|
1450
1736
|
|
|
1451
|
-
const
|
|
1737
|
+
const shell = findAvailableShell();
|
|
1738
|
+
const argv = this.spec.mode === 'shell' ? [shell.cmd, ...shell.args, this.spec.command] : [this.spec.file, ...this.spec.args];
|
|
1452
1739
|
trace('ProcessRunner', () => `Constructed argv | ${JSON.stringify({
|
|
1453
1740
|
mode: this.spec.mode,
|
|
1454
1741
|
argv: argv,
|
|
@@ -1506,6 +1793,14 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1506
1793
|
// For non-interactive commands, spawn with detached to create process group (for proper signal handling)
|
|
1507
1794
|
// This allows us to send signals to the entire process group, killing shell and all its children
|
|
1508
1795
|
trace('ProcessRunner', () => `spawnBun: Using non-interactive mode with pipes and detached=${process.platform !== 'win32'}`);
|
|
1796
|
+
trace('ProcessRunner', () => `spawnBun: About to spawn | ${JSON.stringify({
|
|
1797
|
+
argv,
|
|
1798
|
+
cwd,
|
|
1799
|
+
shellCmd: argv[0],
|
|
1800
|
+
shellArgs: argv.slice(1, -1),
|
|
1801
|
+
command: argv[argv.length - 1]?.slice(0, 50)
|
|
1802
|
+
}, null, 2)}`);
|
|
1803
|
+
|
|
1509
1804
|
const child = Bun.spawn(argv, {
|
|
1510
1805
|
cwd,
|
|
1511
1806
|
env,
|
|
@@ -1856,7 +2151,16 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1856
2151
|
}
|
|
1857
2152
|
|
|
1858
2153
|
async _pumpStdinTo(child, captureChunks) {
|
|
1859
|
-
|
|
2154
|
+
trace('ProcessRunner', () => `_pumpStdinTo ENTER | ${JSON.stringify({
|
|
2155
|
+
hasChildStdin: !!child?.stdin,
|
|
2156
|
+
willCapture: !!captureChunks,
|
|
2157
|
+
isBun
|
|
2158
|
+
}, null, 2)}`);
|
|
2159
|
+
|
|
2160
|
+
if (!child.stdin) {
|
|
2161
|
+
trace('ProcessRunner', () => 'No child stdin to pump to');
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
1860
2164
|
const bunWriter = isBun && child.stdin && typeof child.stdin.getWriter === 'function' ? child.stdin.getWriter() : null;
|
|
1861
2165
|
for await (const chunk of process.stdin) {
|
|
1862
2166
|
const buf = asBuffer(chunk);
|
|
@@ -1874,6 +2178,11 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1874
2178
|
}
|
|
1875
2179
|
|
|
1876
2180
|
async _writeToStdin(buf) {
|
|
2181
|
+
trace('ProcessRunner', () => `_writeToStdin ENTER | ${JSON.stringify({
|
|
2182
|
+
bufferLength: buf?.length || 0,
|
|
2183
|
+
hasChildStdin: !!this.child?.stdin
|
|
2184
|
+
}, null, 2)}`);
|
|
2185
|
+
|
|
1877
2186
|
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength);
|
|
1878
2187
|
if (await StreamUtils.writeToStream(this.child.stdin, bytes, 'stdin')) {
|
|
1879
2188
|
// Successfully wrote to stream
|
|
@@ -1888,8 +2197,16 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1888
2197
|
}
|
|
1889
2198
|
|
|
1890
2199
|
_parseCommand(command) {
|
|
2200
|
+
trace('ProcessRunner', () => `_parseCommand ENTER | ${JSON.stringify({
|
|
2201
|
+
commandLength: command?.length || 0,
|
|
2202
|
+
preview: command?.slice(0, 50)
|
|
2203
|
+
}, null, 2)}`);
|
|
2204
|
+
|
|
1891
2205
|
const trimmed = command.trim();
|
|
1892
|
-
if (!trimmed)
|
|
2206
|
+
if (!trimmed) {
|
|
2207
|
+
trace('ProcessRunner', () => 'Empty command after trimming');
|
|
2208
|
+
return null;
|
|
2209
|
+
}
|
|
1893
2210
|
|
|
1894
2211
|
if (trimmed.includes('|')) {
|
|
1895
2212
|
return this._parsePipeline(trimmed);
|
|
@@ -1913,6 +2230,11 @@ class ProcessRunner extends StreamEmitter {
|
|
|
1913
2230
|
}
|
|
1914
2231
|
|
|
1915
2232
|
_parsePipeline(command) {
|
|
2233
|
+
trace('ProcessRunner', () => `_parsePipeline ENTER | ${JSON.stringify({
|
|
2234
|
+
commandLength: command?.length || 0,
|
|
2235
|
+
hasPipe: command?.includes('|')
|
|
2236
|
+
}, null, 2)}`);
|
|
2237
|
+
|
|
1916
2238
|
// Split by pipe, respecting quotes
|
|
1917
2239
|
const segments = [];
|
|
1918
2240
|
let current = '';
|
|
@@ -2220,8 +2542,17 @@ class ProcessRunner extends StreamEmitter {
|
|
|
2220
2542
|
|
|
2221
2543
|
return result;
|
|
2222
2544
|
} catch (error) {
|
|
2545
|
+
// Check if this is a cancellation error
|
|
2546
|
+
let exitCode = error.code ?? 1;
|
|
2547
|
+
if (this._cancelled && this._cancellationSignal) {
|
|
2548
|
+
// Use appropriate exit code based on the signal
|
|
2549
|
+
exitCode = this._cancellationSignal === 'SIGINT' ? 130 :
|
|
2550
|
+
this._cancellationSignal === 'SIGTERM' ? 143 : 1;
|
|
2551
|
+
trace('ProcessRunner', () => `Virtual command error during cancellation, using signal-based exit code: ${exitCode}`);
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2223
2554
|
const result = {
|
|
2224
|
-
code:
|
|
2555
|
+
code: exitCode,
|
|
2225
2556
|
stdout: error.stdout ?? '',
|
|
2226
2557
|
stderr: error.stderr ?? error.message,
|
|
2227
2558
|
stdin: ''
|
|
@@ -2342,8 +2673,9 @@ class ProcessRunner extends StreamEmitter {
|
|
|
2342
2673
|
commandStr.includes('&&') || commandStr.includes('||') ||
|
|
2343
2674
|
commandStr.includes(';') || commandStr.includes('`');
|
|
2344
2675
|
|
|
2676
|
+
const shell = findAvailableShell();
|
|
2345
2677
|
const spawnArgs = needsShell
|
|
2346
|
-
? [
|
|
2678
|
+
? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
|
|
2347
2679
|
: [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
|
|
2348
2680
|
|
|
2349
2681
|
const proc = Bun.spawn(spawnArgs, {
|
|
@@ -2505,8 +2837,9 @@ class ProcessRunner extends StreamEmitter {
|
|
|
2505
2837
|
commandStr.includes('&&') || commandStr.includes('||') ||
|
|
2506
2838
|
commandStr.includes(';') || commandStr.includes('`');
|
|
2507
2839
|
|
|
2840
|
+
const shell = findAvailableShell();
|
|
2508
2841
|
const spawnArgs = needsShell
|
|
2509
|
-
? [
|
|
2842
|
+
? [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr]
|
|
2510
2843
|
: [cmd, ...args.map(a => a.value !== undefined ? a.value : a)];
|
|
2511
2844
|
|
|
2512
2845
|
const proc = Bun.spawn(spawnArgs, {
|
|
@@ -2743,7 +3076,8 @@ class ProcessRunner extends StreamEmitter {
|
|
|
2743
3076
|
}
|
|
2744
3077
|
const commandStr = commandParts.join(' ');
|
|
2745
3078
|
|
|
2746
|
-
const
|
|
3079
|
+
const shell = findAvailableShell();
|
|
3080
|
+
const proc = Bun.spawn([shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr], {
|
|
2747
3081
|
cwd: this.options.cwd,
|
|
2748
3082
|
env: this.options.env,
|
|
2749
3083
|
stdin: currentInputStream ? 'pipe' : 'ignore',
|
|
@@ -3131,7 +3465,8 @@ class ProcessRunner extends StreamEmitter {
|
|
|
3131
3465
|
};
|
|
3132
3466
|
|
|
3133
3467
|
// Execute using shell to handle complex commands
|
|
3134
|
-
const
|
|
3468
|
+
const shell = findAvailableShell();
|
|
3469
|
+
const argv = [shell.cmd, ...shell.args.filter(arg => arg !== '-l'), commandStr];
|
|
3135
3470
|
const isLastCommand = (i === commands.length - 1);
|
|
3136
3471
|
const proc = await spawnNodeAsync(argv, currentInput, isLastCommand);
|
|
3137
3472
|
|
|
@@ -3306,14 +3641,181 @@ class ProcessRunner extends StreamEmitter {
|
|
|
3306
3641
|
}
|
|
3307
3642
|
}
|
|
3308
3643
|
|
|
3644
|
+
async _runSequence(sequence) {
|
|
3645
|
+
trace('ProcessRunner', () => `_runSequence ENTER | ${JSON.stringify({
|
|
3646
|
+
commandCount: sequence.commands.length,
|
|
3647
|
+
operators: sequence.operators
|
|
3648
|
+
}, null, 2)}`);
|
|
3649
|
+
|
|
3650
|
+
let lastResult = { code: 0, stdout: '', stderr: '' };
|
|
3651
|
+
let combinedStdout = '';
|
|
3652
|
+
let combinedStderr = '';
|
|
3653
|
+
|
|
3654
|
+
for (let i = 0; i < sequence.commands.length; i++) {
|
|
3655
|
+
const command = sequence.commands[i];
|
|
3656
|
+
const operator = i > 0 ? sequence.operators[i - 1] : null;
|
|
3657
|
+
|
|
3658
|
+
trace('ProcessRunner', () => `Executing command ${i} | ${JSON.stringify({
|
|
3659
|
+
command: command.type,
|
|
3660
|
+
operator,
|
|
3661
|
+
lastCode: lastResult.code
|
|
3662
|
+
}, null, 2)}`);
|
|
3663
|
+
|
|
3664
|
+
// Check operator conditions
|
|
3665
|
+
if (operator === '&&' && lastResult.code !== 0) {
|
|
3666
|
+
trace('ProcessRunner', () => `Skipping due to && with exit code ${lastResult.code}`);
|
|
3667
|
+
continue;
|
|
3668
|
+
}
|
|
3669
|
+
if (operator === '||' && lastResult.code === 0) {
|
|
3670
|
+
trace('ProcessRunner', () => `Skipping due to || with exit code ${lastResult.code}`);
|
|
3671
|
+
continue;
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
// Execute command based on type
|
|
3675
|
+
if (command.type === 'subshell') {
|
|
3676
|
+
lastResult = await this._runSubshell(command);
|
|
3677
|
+
} else if (command.type === 'pipeline') {
|
|
3678
|
+
lastResult = await this._runPipeline(command.commands);
|
|
3679
|
+
} else if (command.type === 'sequence') {
|
|
3680
|
+
lastResult = await this._runSequence(command);
|
|
3681
|
+
} else if (command.type === 'simple') {
|
|
3682
|
+
lastResult = await this._runSimpleCommand(command);
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
// Accumulate output
|
|
3686
|
+
combinedStdout += lastResult.stdout;
|
|
3687
|
+
combinedStderr += lastResult.stderr;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
return {
|
|
3691
|
+
code: lastResult.code,
|
|
3692
|
+
stdout: combinedStdout,
|
|
3693
|
+
stderr: combinedStderr,
|
|
3694
|
+
async text() {
|
|
3695
|
+
return combinedStdout;
|
|
3696
|
+
}
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
async _runSubshell(subshell) {
|
|
3701
|
+
trace('ProcessRunner', () => `_runSubshell ENTER | ${JSON.stringify({
|
|
3702
|
+
commandType: subshell.command.type
|
|
3703
|
+
}, null, 2)}`);
|
|
3704
|
+
|
|
3705
|
+
// Save current directory
|
|
3706
|
+
const savedCwd = process.cwd();
|
|
3707
|
+
|
|
3708
|
+
try {
|
|
3709
|
+
// Execute subshell command
|
|
3710
|
+
let result;
|
|
3711
|
+
if (subshell.command.type === 'sequence') {
|
|
3712
|
+
result = await this._runSequence(subshell.command);
|
|
3713
|
+
} else if (subshell.command.type === 'pipeline') {
|
|
3714
|
+
result = await this._runPipeline(subshell.command.commands);
|
|
3715
|
+
} else if (subshell.command.type === 'simple') {
|
|
3716
|
+
result = await this._runSimpleCommand(subshell.command);
|
|
3717
|
+
} else {
|
|
3718
|
+
result = { code: 0, stdout: '', stderr: '' };
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
return result;
|
|
3722
|
+
} finally {
|
|
3723
|
+
// Restore directory - check if it still exists first
|
|
3724
|
+
trace('ProcessRunner', () => `Restoring cwd from ${process.cwd()} to ${savedCwd}`);
|
|
3725
|
+
const fs = await import('fs');
|
|
3726
|
+
if (fs.existsSync(savedCwd)) {
|
|
3727
|
+
process.chdir(savedCwd);
|
|
3728
|
+
} else {
|
|
3729
|
+
// If the saved directory was deleted, try to go to a safe location
|
|
3730
|
+
const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/';
|
|
3731
|
+
trace('ProcessRunner', () => `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}`);
|
|
3732
|
+
try {
|
|
3733
|
+
process.chdir(fallbackDir);
|
|
3734
|
+
} catch (e) {
|
|
3735
|
+
// If even fallback fails, just stay where we are
|
|
3736
|
+
trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
async _runSimpleCommand(command) {
|
|
3743
|
+
trace('ProcessRunner', () => `_runSimpleCommand ENTER | ${JSON.stringify({
|
|
3744
|
+
cmd: command.cmd,
|
|
3745
|
+
argsCount: command.args?.length || 0,
|
|
3746
|
+
hasRedirects: !!command.redirects
|
|
3747
|
+
}, null, 2)}`);
|
|
3748
|
+
|
|
3749
|
+
const { cmd, args, redirects } = command;
|
|
3750
|
+
|
|
3751
|
+
// Check for virtual command
|
|
3752
|
+
if (virtualCommandsEnabled && virtualCommands.has(cmd)) {
|
|
3753
|
+
trace('ProcessRunner', () => `Using virtual command: ${cmd}`);
|
|
3754
|
+
const argValues = args.map(a => a.value || a);
|
|
3755
|
+
const result = await this._runVirtual(cmd, argValues);
|
|
3756
|
+
|
|
3757
|
+
// Handle output redirection for virtual commands
|
|
3758
|
+
if (redirects && redirects.length > 0) {
|
|
3759
|
+
for (const redirect of redirects) {
|
|
3760
|
+
if (redirect.type === '>' || redirect.type === '>>') {
|
|
3761
|
+
const fs = await import('fs');
|
|
3762
|
+
if (redirect.type === '>') {
|
|
3763
|
+
fs.writeFileSync(redirect.target, result.stdout);
|
|
3764
|
+
} else {
|
|
3765
|
+
fs.appendFileSync(redirect.target, result.stdout);
|
|
3766
|
+
}
|
|
3767
|
+
// Clear stdout since it was redirected
|
|
3768
|
+
result.stdout = '';
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
return result;
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
// Build command string for real execution
|
|
3777
|
+
let commandStr = cmd;
|
|
3778
|
+
for (const arg of args) {
|
|
3779
|
+
if (arg.quoted && arg.quoteChar) {
|
|
3780
|
+
commandStr += ` ${arg.quoteChar}${arg.value}${arg.quoteChar}`;
|
|
3781
|
+
} else if (arg.value !== undefined) {
|
|
3782
|
+
commandStr += ` ${arg.value}`;
|
|
3783
|
+
} else {
|
|
3784
|
+
commandStr += ` ${arg}`;
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
// Add redirections
|
|
3789
|
+
if (redirects) {
|
|
3790
|
+
for (const redirect of redirects) {
|
|
3791
|
+
commandStr += ` ${redirect.type} ${redirect.target}`;
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
trace('ProcessRunner', () => `Executing real command: ${commandStr}`);
|
|
3796
|
+
|
|
3797
|
+
// Create a new ProcessRunner for the real command
|
|
3798
|
+
// Use current working directory since cd virtual command may have changed it
|
|
3799
|
+
const runner = new ProcessRunner(
|
|
3800
|
+
{ mode: 'shell', command: commandStr },
|
|
3801
|
+
{ ...this.options, cwd: process.cwd(), _bypassVirtual: true }
|
|
3802
|
+
);
|
|
3803
|
+
|
|
3804
|
+
return await runner;
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3309
3807
|
async* stream() {
|
|
3310
3808
|
trace('ProcessRunner', () => `stream ENTER | ${JSON.stringify({
|
|
3311
3809
|
started: this.started,
|
|
3312
|
-
finished: this.finished
|
|
3810
|
+
finished: this.finished,
|
|
3811
|
+
command: this.spec?.command?.slice(0, 100)
|
|
3313
3812
|
}, null, 2)}`);
|
|
3314
3813
|
|
|
3814
|
+
// Mark that we're in streaming mode to bypass shell operator interception
|
|
3815
|
+
this._isStreaming = true;
|
|
3816
|
+
|
|
3315
3817
|
if (!this.started) {
|
|
3316
|
-
trace('ProcessRunner', () => 'Auto-starting async process from stream()');
|
|
3818
|
+
trace('ProcessRunner', () => 'Auto-starting async process from stream() with streaming mode');
|
|
3317
3819
|
this._startAsync(); // Start but don't await
|
|
3318
3820
|
}
|
|
3319
3821
|
|
|
@@ -3637,6 +4139,12 @@ class ProcessRunner extends StreamEmitter {
|
|
|
3637
4139
|
|
|
3638
4140
|
// Promise interface (for await)
|
|
3639
4141
|
then(onFulfilled, onRejected) {
|
|
4142
|
+
trace('ProcessRunner', () => `then() called | ${JSON.stringify({
|
|
4143
|
+
hasPromise: !!this.promise,
|
|
4144
|
+
started: this.started,
|
|
4145
|
+
finished: this.finished
|
|
4146
|
+
}, null, 2)}`);
|
|
4147
|
+
|
|
3640
4148
|
if (!this.promise) {
|
|
3641
4149
|
this.promise = this._startAsync();
|
|
3642
4150
|
}
|
|
@@ -3644,6 +4152,12 @@ class ProcessRunner extends StreamEmitter {
|
|
|
3644
4152
|
}
|
|
3645
4153
|
|
|
3646
4154
|
catch(onRejected) {
|
|
4155
|
+
trace('ProcessRunner', () => `catch() called | ${JSON.stringify({
|
|
4156
|
+
hasPromise: !!this.promise,
|
|
4157
|
+
started: this.started,
|
|
4158
|
+
finished: this.finished
|
|
4159
|
+
}, null, 2)}`);
|
|
4160
|
+
|
|
3647
4161
|
if (!this.promise) {
|
|
3648
4162
|
this.promise = this._startAsync();
|
|
3649
4163
|
}
|
|
@@ -3651,6 +4165,12 @@ class ProcessRunner extends StreamEmitter {
|
|
|
3651
4165
|
}
|
|
3652
4166
|
|
|
3653
4167
|
finally(onFinally) {
|
|
4168
|
+
trace('ProcessRunner', () => `finally() called | ${JSON.stringify({
|
|
4169
|
+
hasPromise: !!this.promise,
|
|
4170
|
+
started: this.started,
|
|
4171
|
+
finished: this.finished
|
|
4172
|
+
}, null, 2)}`);
|
|
4173
|
+
|
|
3654
4174
|
if (!this.promise) {
|
|
3655
4175
|
this.promise = this._startAsync();
|
|
3656
4176
|
}
|
|
@@ -3687,7 +4207,8 @@ class ProcessRunner extends StreamEmitter {
|
|
|
3687
4207
|
trace('ProcessRunner', () => `Starting sync execution | ${JSON.stringify({ mode: this._mode }, null, 2)}`);
|
|
3688
4208
|
|
|
3689
4209
|
const { cwd, env, stdin } = this.options;
|
|
3690
|
-
const
|
|
4210
|
+
const shell = findAvailableShell();
|
|
4211
|
+
const argv = this.spec.mode === 'shell' ? [shell.cmd, ...shell.args, this.spec.command] : [this.spec.file, ...this.spec.args];
|
|
3691
4212
|
|
|
3692
4213
|
if (globalShellSettings.xtrace) {
|
|
3693
4214
|
const traceCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
|
|
@@ -3879,10 +4400,12 @@ function create(defaultOptions = {}) {
|
|
|
3879
4400
|
}
|
|
3880
4401
|
|
|
3881
4402
|
function raw(value) {
|
|
4403
|
+
trace('API', () => `raw() called with value: ${String(value).slice(0, 50)}`);
|
|
3882
4404
|
return { raw: String(value) };
|
|
3883
4405
|
}
|
|
3884
4406
|
|
|
3885
4407
|
function set(option) {
|
|
4408
|
+
trace('API', () => `set() called with option: ${option}`);
|
|
3886
4409
|
const mapping = {
|
|
3887
4410
|
'e': 'errexit', // set -e: exit on error
|
|
3888
4411
|
'errexit': 'errexit',
|
|
@@ -3906,6 +4429,7 @@ function set(option) {
|
|
|
3906
4429
|
}
|
|
3907
4430
|
|
|
3908
4431
|
function unset(option) {
|
|
4432
|
+
trace('API', () => `unset() called with option: ${option}`);
|
|
3909
4433
|
const mapping = {
|
|
3910
4434
|
'e': 'errexit',
|
|
3911
4435
|
'errexit': 'errexit',
|
|
@@ -3958,15 +4482,19 @@ function unregister(name) {
|
|
|
3958
4482
|
}
|
|
3959
4483
|
|
|
3960
4484
|
function listCommands() {
|
|
3961
|
-
|
|
4485
|
+
const commands = Array.from(virtualCommands.keys());
|
|
4486
|
+
trace('VirtualCommands', () => `listCommands() returning ${commands.length} commands`);
|
|
4487
|
+
return commands;
|
|
3962
4488
|
}
|
|
3963
4489
|
|
|
3964
4490
|
function enableVirtualCommands() {
|
|
4491
|
+
trace('VirtualCommands', () => 'Enabling virtual commands');
|
|
3965
4492
|
virtualCommandsEnabled = true;
|
|
3966
4493
|
return virtualCommandsEnabled;
|
|
3967
4494
|
}
|
|
3968
4495
|
|
|
3969
4496
|
function disableVirtualCommands() {
|
|
4497
|
+
trace('VirtualCommands', () => 'Disabling virtual commands');
|
|
3970
4498
|
virtualCommandsEnabled = false;
|
|
3971
4499
|
return virtualCommandsEnabled;
|
|
3972
4500
|
}
|
|
@@ -3996,6 +4524,7 @@ import testCommand from './commands/$.test.mjs';
|
|
|
3996
4524
|
|
|
3997
4525
|
// Built-in commands that match Bun.$ functionality
|
|
3998
4526
|
function registerBuiltins() {
|
|
4527
|
+
trace('VirtualCommands', () => 'registerBuiltins() called - registering all built-in commands');
|
|
3999
4528
|
// Register all imported commands
|
|
4000
4529
|
register('cd', cdCommand);
|
|
4001
4530
|
register('pwd', pwdCommand);
|
|
@@ -4030,12 +4559,14 @@ const AnsiUtils = {
|
|
|
4030
4559
|
|
|
4031
4560
|
stripControlChars(text) {
|
|
4032
4561
|
if (typeof text !== 'string') return text;
|
|
4033
|
-
|
|
4562
|
+
// Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
|
|
4563
|
+
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
4034
4564
|
},
|
|
4035
4565
|
|
|
4036
4566
|
stripAll(text) {
|
|
4037
4567
|
if (typeof text !== 'string') return text;
|
|
4038
|
-
|
|
4568
|
+
// Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09)
|
|
4569
|
+
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1b\[[0-9;]*[mGKHFJ]/g, '');
|
|
4039
4570
|
},
|
|
4040
4571
|
|
|
4041
4572
|
cleanForProcessing(data) {
|
|
@@ -4052,15 +4583,23 @@ let globalAnsiConfig = {
|
|
|
4052
4583
|
};
|
|
4053
4584
|
|
|
4054
4585
|
function configureAnsi(options = {}) {
|
|
4586
|
+
trace('AnsiUtils', () => `configureAnsi() called | ${JSON.stringify({ options }, null, 2)}`);
|
|
4055
4587
|
globalAnsiConfig = { ...globalAnsiConfig, ...options };
|
|
4588
|
+
trace('AnsiUtils', () => `New ANSI config | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
|
|
4056
4589
|
return globalAnsiConfig;
|
|
4057
4590
|
}
|
|
4058
4591
|
|
|
4059
4592
|
function getAnsiConfig() {
|
|
4593
|
+
trace('AnsiUtils', () => `getAnsiConfig() returning | ${JSON.stringify({ globalAnsiConfig }, null, 2)}`);
|
|
4060
4594
|
return { ...globalAnsiConfig };
|
|
4061
4595
|
}
|
|
4062
4596
|
|
|
4063
4597
|
function processOutput(data, options = {}) {
|
|
4598
|
+
trace('AnsiUtils', () => `processOutput() called | ${JSON.stringify({
|
|
4599
|
+
dataType: typeof data,
|
|
4600
|
+
dataLength: Buffer.isBuffer(data) ? data.length : data?.length,
|
|
4601
|
+
options
|
|
4602
|
+
}, null, 2)}`);
|
|
4064
4603
|
const config = { ...globalAnsiConfig, ...options };
|
|
4065
4604
|
if (!config.preserveAnsi && !config.preserveControlChars) {
|
|
4066
4605
|
return AnsiUtils.cleanForProcessing(data);
|
|
@@ -4077,7 +4616,9 @@ function processOutput(data, options = {}) {
|
|
|
4077
4616
|
}
|
|
4078
4617
|
|
|
4079
4618
|
// Initialize built-in commands
|
|
4619
|
+
trace('Initialization', () => 'Registering built-in virtual commands');
|
|
4080
4620
|
registerBuiltins();
|
|
4621
|
+
trace('Initialization', () => `Built-in commands registered: ${listCommands().join(', ')}`);
|
|
4081
4622
|
|
|
4082
4623
|
export {
|
|
4083
4624
|
$tagged as $,
|
|
@@ -4090,6 +4631,7 @@ export {
|
|
|
4090
4631
|
ProcessRunner,
|
|
4091
4632
|
shell,
|
|
4092
4633
|
set,
|
|
4634
|
+
resetGlobalState,
|
|
4093
4635
|
unset,
|
|
4094
4636
|
register,
|
|
4095
4637
|
unregister,
|
package/src/commands/$.cd.mjs
CHANGED
|
@@ -8,9 +8,10 @@ export default async function cd({ args }) {
|
|
|
8
8
|
process.chdir(target);
|
|
9
9
|
const newDir = process.cwd();
|
|
10
10
|
trace('VirtualCommand', () => `cd: success | ${JSON.stringify({ newDir }, null, 2)}`);
|
|
11
|
-
|
|
11
|
+
// cd command should not output anything on success, just like real cd
|
|
12
|
+
return VirtualUtils.success('');
|
|
12
13
|
} catch (error) {
|
|
13
14
|
trace('VirtualCommand', () => `cd: failed | ${JSON.stringify({ error: error.message }, null, 2)}`);
|
|
14
|
-
return { stderr: `cd: ${error.message}`, code: 1 };
|
|
15
|
+
return { stderr: `cd: ${error.message}\n`, code: 1 };
|
|
15
16
|
}
|
|
16
17
|
}
|
package/src/commands/$.pwd.mjs
CHANGED
|
@@ -4,5 +4,5 @@ export default async function pwd({ args, stdin, cwd }) {
|
|
|
4
4
|
// If cwd option is provided, return that instead of process.cwd()
|
|
5
5
|
const dir = cwd || process.cwd();
|
|
6
6
|
trace('VirtualCommand', () => `pwd: getting directory | ${JSON.stringify({ dir }, null, 2)}`);
|
|
7
|
-
return VirtualUtils.success(dir);
|
|
7
|
+
return VirtualUtils.success(dir + '\n');
|
|
8
8
|
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced shell command parser that handles &&, ||, ;, and () operators
|
|
3
|
+
* This allows virtual commands to work properly with shell operators
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { trace } from './$.utils.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Token types for the parser
|
|
10
|
+
*/
|
|
11
|
+
const TokenType = {
|
|
12
|
+
WORD: 'word',
|
|
13
|
+
AND: '&&',
|
|
14
|
+
OR: '||',
|
|
15
|
+
SEMICOLON: ';',
|
|
16
|
+
PIPE: '|',
|
|
17
|
+
LPAREN: '(',
|
|
18
|
+
RPAREN: ')',
|
|
19
|
+
REDIRECT_OUT: '>',
|
|
20
|
+
REDIRECT_APPEND: '>>',
|
|
21
|
+
REDIRECT_IN: '<',
|
|
22
|
+
EOF: 'eof'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tokenize a shell command string
|
|
27
|
+
*/
|
|
28
|
+
function tokenize(command) {
|
|
29
|
+
const tokens = [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
|
|
32
|
+
while (i < command.length) {
|
|
33
|
+
// Skip whitespace
|
|
34
|
+
while (i < command.length && /\s/.test(command[i])) {
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (i >= command.length) break;
|
|
39
|
+
|
|
40
|
+
// Check for operators
|
|
41
|
+
if (command[i] === '&' && command[i + 1] === '&') {
|
|
42
|
+
tokens.push({ type: TokenType.AND, value: '&&' });
|
|
43
|
+
i += 2;
|
|
44
|
+
} else if (command[i] === '|' && command[i + 1] === '|') {
|
|
45
|
+
tokens.push({ type: TokenType.OR, value: '||' });
|
|
46
|
+
i += 2;
|
|
47
|
+
} else if (command[i] === '|') {
|
|
48
|
+
tokens.push({ type: TokenType.PIPE, value: '|' });
|
|
49
|
+
i++;
|
|
50
|
+
} else if (command[i] === ';') {
|
|
51
|
+
tokens.push({ type: TokenType.SEMICOLON, value: ';' });
|
|
52
|
+
i++;
|
|
53
|
+
} else if (command[i] === '(') {
|
|
54
|
+
tokens.push({ type: TokenType.LPAREN, value: '(' });
|
|
55
|
+
i++;
|
|
56
|
+
} else if (command[i] === ')') {
|
|
57
|
+
tokens.push({ type: TokenType.RPAREN, value: ')' });
|
|
58
|
+
i++;
|
|
59
|
+
} else if (command[i] === '>' && command[i + 1] === '>') {
|
|
60
|
+
tokens.push({ type: TokenType.REDIRECT_APPEND, value: '>>' });
|
|
61
|
+
i += 2;
|
|
62
|
+
} else if (command[i] === '>') {
|
|
63
|
+
tokens.push({ type: TokenType.REDIRECT_OUT, value: '>' });
|
|
64
|
+
i++;
|
|
65
|
+
} else if (command[i] === '<') {
|
|
66
|
+
tokens.push({ type: TokenType.REDIRECT_IN, value: '<' });
|
|
67
|
+
i++;
|
|
68
|
+
} else {
|
|
69
|
+
// Parse word (respecting quotes)
|
|
70
|
+
let word = '';
|
|
71
|
+
let inQuote = false;
|
|
72
|
+
let quoteChar = '';
|
|
73
|
+
|
|
74
|
+
while (i < command.length) {
|
|
75
|
+
const char = command[i];
|
|
76
|
+
|
|
77
|
+
if (!inQuote) {
|
|
78
|
+
if (char === '"' || char === "'") {
|
|
79
|
+
inQuote = true;
|
|
80
|
+
quoteChar = char;
|
|
81
|
+
word += char;
|
|
82
|
+
i++;
|
|
83
|
+
} else if (/\s/.test(char) ||
|
|
84
|
+
'&|;()<>'.includes(char)) {
|
|
85
|
+
break;
|
|
86
|
+
} else if (char === '\\' && i + 1 < command.length) {
|
|
87
|
+
// Handle escape sequences
|
|
88
|
+
word += char;
|
|
89
|
+
i++;
|
|
90
|
+
if (i < command.length) {
|
|
91
|
+
word += command[i];
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
word += char;
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
if (char === quoteChar && command[i - 1] !== '\\') {
|
|
100
|
+
inQuote = false;
|
|
101
|
+
quoteChar = '';
|
|
102
|
+
word += char;
|
|
103
|
+
i++;
|
|
104
|
+
} else if (char === '\\' && i + 1 < command.length &&
|
|
105
|
+
(command[i + 1] === quoteChar || command[i + 1] === '\\')) {
|
|
106
|
+
// Handle escaped quotes and backslashes inside quotes
|
|
107
|
+
word += char;
|
|
108
|
+
i++;
|
|
109
|
+
if (i < command.length) {
|
|
110
|
+
word += command[i];
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
word += char;
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (word) {
|
|
121
|
+
tokens.push({ type: TokenType.WORD, value: word });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
tokens.push({ type: TokenType.EOF, value: '' });
|
|
127
|
+
return tokens;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse a sequence of commands with operators
|
|
132
|
+
*/
|
|
133
|
+
class ShellParser {
|
|
134
|
+
constructor(command) {
|
|
135
|
+
this.tokens = tokenize(command);
|
|
136
|
+
this.pos = 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
current() {
|
|
140
|
+
return this.tokens[this.pos] || { type: TokenType.EOF, value: '' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
peek() {
|
|
144
|
+
return this.tokens[this.pos + 1] || { type: TokenType.EOF, value: '' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
consume() {
|
|
148
|
+
const token = this.current();
|
|
149
|
+
this.pos++;
|
|
150
|
+
return token;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse the main command sequence
|
|
155
|
+
*/
|
|
156
|
+
parse() {
|
|
157
|
+
return this.parseSequence();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse a sequence of commands connected by &&, ||, ;
|
|
162
|
+
*/
|
|
163
|
+
parseSequence() {
|
|
164
|
+
const commands = [];
|
|
165
|
+
const operators = [];
|
|
166
|
+
|
|
167
|
+
// Parse first command
|
|
168
|
+
let cmd = this.parsePipeline();
|
|
169
|
+
if (cmd) {
|
|
170
|
+
commands.push(cmd);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Parse additional commands with operators
|
|
174
|
+
while (this.current().type !== TokenType.EOF &&
|
|
175
|
+
this.current().type !== TokenType.RPAREN) {
|
|
176
|
+
const op = this.current();
|
|
177
|
+
|
|
178
|
+
if (op.type === TokenType.AND ||
|
|
179
|
+
op.type === TokenType.OR ||
|
|
180
|
+
op.type === TokenType.SEMICOLON) {
|
|
181
|
+
operators.push(op.type);
|
|
182
|
+
this.consume();
|
|
183
|
+
|
|
184
|
+
cmd = this.parsePipeline();
|
|
185
|
+
if (cmd) {
|
|
186
|
+
commands.push(cmd);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (commands.length === 1 && operators.length === 0) {
|
|
194
|
+
return commands[0];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
type: 'sequence',
|
|
199
|
+
commands,
|
|
200
|
+
operators
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Parse a pipeline (commands connected by |)
|
|
206
|
+
*/
|
|
207
|
+
parsePipeline() {
|
|
208
|
+
const commands = [];
|
|
209
|
+
|
|
210
|
+
let cmd = this.parseCommand();
|
|
211
|
+
if (cmd) {
|
|
212
|
+
commands.push(cmd);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
while (this.current().type === TokenType.PIPE) {
|
|
216
|
+
this.consume();
|
|
217
|
+
cmd = this.parseCommand();
|
|
218
|
+
if (cmd) {
|
|
219
|
+
commands.push(cmd);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (commands.length === 1) {
|
|
224
|
+
return commands[0];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
type: 'pipeline',
|
|
229
|
+
commands
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parse a single command or subshell
|
|
235
|
+
*/
|
|
236
|
+
parseCommand() {
|
|
237
|
+
// Check for subshell
|
|
238
|
+
if (this.current().type === TokenType.LPAREN) {
|
|
239
|
+
this.consume(); // consume (
|
|
240
|
+
const subshell = this.parseSequence();
|
|
241
|
+
|
|
242
|
+
if (this.current().type === TokenType.RPAREN) {
|
|
243
|
+
this.consume(); // consume )
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
type: 'subshell',
|
|
248
|
+
command: subshell
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Parse simple command
|
|
253
|
+
return this.parseSimpleCommand();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Parse a simple command (command + args + redirections)
|
|
258
|
+
*/
|
|
259
|
+
parseSimpleCommand() {
|
|
260
|
+
const words = [];
|
|
261
|
+
const redirects = [];
|
|
262
|
+
|
|
263
|
+
while (this.current().type !== TokenType.EOF) {
|
|
264
|
+
const token = this.current();
|
|
265
|
+
|
|
266
|
+
if (token.type === TokenType.WORD) {
|
|
267
|
+
words.push(token.value);
|
|
268
|
+
this.consume();
|
|
269
|
+
} else if (token.type === TokenType.REDIRECT_OUT ||
|
|
270
|
+
token.type === TokenType.REDIRECT_APPEND ||
|
|
271
|
+
token.type === TokenType.REDIRECT_IN) {
|
|
272
|
+
this.consume();
|
|
273
|
+
const target = this.current();
|
|
274
|
+
if (target.type === TokenType.WORD) {
|
|
275
|
+
redirects.push({
|
|
276
|
+
type: token.type,
|
|
277
|
+
target: target.value
|
|
278
|
+
});
|
|
279
|
+
this.consume();
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (words.length === 0) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const cmd = words[0];
|
|
291
|
+
const args = words.slice(1).map(word => {
|
|
292
|
+
// Remove quotes if present
|
|
293
|
+
if ((word.startsWith('"') && word.endsWith('"')) ||
|
|
294
|
+
(word.startsWith("'") && word.endsWith("'"))) {
|
|
295
|
+
return {
|
|
296
|
+
value: word.slice(1, -1),
|
|
297
|
+
quoted: true,
|
|
298
|
+
quoteChar: word[0]
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
value: word,
|
|
303
|
+
quoted: false
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const result = {
|
|
308
|
+
type: 'simple',
|
|
309
|
+
cmd,
|
|
310
|
+
args
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (redirects.length > 0) {
|
|
314
|
+
result.redirects = redirects;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Parse a shell command with support for &&, ||, ;, and ()
|
|
323
|
+
*/
|
|
324
|
+
export function parseShellCommand(command) {
|
|
325
|
+
try {
|
|
326
|
+
const parser = new ShellParser(command);
|
|
327
|
+
const result = parser.parse();
|
|
328
|
+
|
|
329
|
+
trace('ShellParser', () => `Parsed command | ${JSON.stringify({
|
|
330
|
+
input: command.slice(0, 100),
|
|
331
|
+
result
|
|
332
|
+
}, null, 2)}`);
|
|
333
|
+
|
|
334
|
+
return result;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
trace('ShellParser', () => `Parse error | ${JSON.stringify({
|
|
337
|
+
command: command.slice(0, 100),
|
|
338
|
+
error: error.message
|
|
339
|
+
}, null, 2)}`);
|
|
340
|
+
|
|
341
|
+
// Return null to fallback to sh -c
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Check if a command needs shell features we don't handle
|
|
348
|
+
*/
|
|
349
|
+
export function needsRealShell(command) {
|
|
350
|
+
// Check for features we don't handle yet
|
|
351
|
+
const unsupported = [
|
|
352
|
+
'`', // Command substitution
|
|
353
|
+
'$(', // Command substitution
|
|
354
|
+
'${', // Variable expansion
|
|
355
|
+
'~', // Home expansion (at start of word)
|
|
356
|
+
'*', // Glob patterns
|
|
357
|
+
'?', // Glob patterns
|
|
358
|
+
'[', // Glob patterns
|
|
359
|
+
'2>', // stderr redirection
|
|
360
|
+
'&>', // Combined redirection
|
|
361
|
+
'>&', // File descriptor duplication
|
|
362
|
+
'<<', // Here documents
|
|
363
|
+
'<<<', // Here strings
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
for (const feature of unsupported) {
|
|
367
|
+
if (command.includes(feature)) {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default { parseShellCommand, needsRealShell };
|