command-stream 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/js/src/$.ansi.mjs +147 -0
  2. package/js/src/$.mjs +49 -6382
  3. package/js/src/$.process-runner-base.mjs +563 -0
  4. package/js/src/$.process-runner-execution.mjs +1497 -0
  5. package/js/src/$.process-runner-orchestration.mjs +250 -0
  6. package/js/src/$.process-runner-pipeline.mjs +1162 -0
  7. package/js/src/$.process-runner-stream-kill.mjs +312 -0
  8. package/js/src/$.process-runner-virtual.mjs +297 -0
  9. package/js/src/$.quote.mjs +161 -0
  10. package/js/src/$.result.mjs +23 -0
  11. package/js/src/$.shell-settings.mjs +84 -0
  12. package/js/src/$.shell.mjs +157 -0
  13. package/js/src/$.state.mjs +401 -0
  14. package/js/src/$.stream-emitter.mjs +111 -0
  15. package/js/src/$.stream-utils.mjs +390 -0
  16. package/js/src/$.trace.mjs +36 -0
  17. package/js/src/$.utils.mjs +2 -23
  18. package/js/src/$.virtual-commands.mjs +113 -0
  19. package/js/src/commands/$.which.mjs +3 -1
  20. package/js/src/commands/index.mjs +24 -0
  21. package/js/src/shell-parser.mjs +125 -83
  22. package/js/tests/resource-cleanup-internals.test.mjs +22 -24
  23. package/js/tests/sigint-cleanup.test.mjs +3 -0
  24. package/package.json +1 -1
  25. package/rust/src/ansi.rs +194 -0
  26. package/rust/src/events.rs +305 -0
  27. package/rust/src/lib.rs +71 -60
  28. package/rust/src/macros.rs +165 -0
  29. package/rust/src/pipeline.rs +411 -0
  30. package/rust/src/quote.rs +161 -0
  31. package/rust/src/state.rs +333 -0
  32. package/rust/src/stream.rs +369 -0
  33. package/rust/src/trace.rs +152 -0
  34. package/rust/src/utils.rs +53 -158
  35. package/rust/tests/events.rs +207 -0
  36. package/rust/tests/macros.rs +77 -0
  37. package/rust/tests/pipeline.rs +93 -0
  38. package/rust/tests/state.rs +207 -0
  39. package/rust/tests/stream.rs +102 -0
@@ -0,0 +1,161 @@
1
+ // Shell quoting and command building utilities
2
+ // Handles safe interpolation of values into shell commands
3
+
4
+ import { trace } from './$.trace.mjs';
5
+
6
+ /**
7
+ * Quote a value for safe shell interpolation
8
+ * @param {*} value - Value to quote
9
+ * @returns {string} Safely quoted string
10
+ */
11
+ export function quote(value) {
12
+ if (value === null || value === undefined) {
13
+ return "''";
14
+ }
15
+ if (Array.isArray(value)) {
16
+ return value.map(quote).join(' ');
17
+ }
18
+ if (typeof value !== 'string') {
19
+ value = String(value);
20
+ }
21
+ if (value === '') {
22
+ return "''";
23
+ }
24
+
25
+ // If the value is already properly quoted and doesn't need further escaping,
26
+ // check if we can use it as-is or with simpler quoting
27
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
28
+ // If it's already single-quoted and doesn't contain unescaped single quotes in the middle,
29
+ // we can potentially use it as-is
30
+ const inner = value.slice(1, -1);
31
+ if (!inner.includes("'")) {
32
+ // The inner content has no single quotes, so the original quoting is fine
33
+ return value;
34
+ }
35
+ }
36
+
37
+ if (value.startsWith('"') && value.endsWith('"') && value.length > 2) {
38
+ // If it's already double-quoted, wrap it in single quotes to preserve it
39
+ return `'${value}'`;
40
+ }
41
+
42
+ // Check if the string needs quoting at all
43
+ // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus
44
+ // This regex matches strings that DON'T need quoting
45
+ const safePattern = /^[a-zA-Z0-9_\-./=,+@:]+$/;
46
+
47
+ if (safePattern.test(value)) {
48
+ // The string is safe and doesn't need quoting
49
+ return value;
50
+ }
51
+
52
+ // Default behavior: wrap in single quotes and escape any internal single quotes
53
+ // This handles spaces, special shell characters, etc.
54
+ return `'${value.replace(/'/g, "'\\''")}'`;
55
+ }
56
+
57
+ /**
58
+ * Build a shell command from template strings and values
59
+ * @param {string[]} strings - Template literal strings
60
+ * @param {*[]} values - Interpolated values
61
+ * @returns {string} Complete shell command
62
+ */
63
+ export function buildShellCommand(strings, values) {
64
+ trace(
65
+ 'Utils',
66
+ () =>
67
+ `buildShellCommand ENTER | ${JSON.stringify(
68
+ {
69
+ stringsLength: strings.length,
70
+ valuesLength: values.length,
71
+ },
72
+ null,
73
+ 2
74
+ )}`
75
+ );
76
+
77
+ // Special case: if we have a single value with empty surrounding strings,
78
+ // and the value looks like a complete shell command, treat it as raw
79
+ if (
80
+ values.length === 1 &&
81
+ strings.length === 2 &&
82
+ strings[0] === '' &&
83
+ strings[1] === '' &&
84
+ typeof values[0] === 'string'
85
+ ) {
86
+ const commandStr = values[0];
87
+ // Check if this looks like a complete shell command (contains spaces and shell-safe characters)
88
+ const commandPattern = /^[a-zA-Z0-9_\-./=,+@:\s"'`$(){}<>|&;*?[\]~\\]+$/;
89
+ if (commandPattern.test(commandStr) && commandStr.trim().length > 0) {
90
+ trace(
91
+ 'Utils',
92
+ () =>
93
+ `BRANCH: buildShellCommand => COMPLETE_COMMAND | ${JSON.stringify({ command: commandStr }, null, 2)}`
94
+ );
95
+ return commandStr;
96
+ }
97
+ }
98
+
99
+ let out = '';
100
+ for (let i = 0; i < strings.length; i++) {
101
+ out += strings[i];
102
+ if (i < values.length) {
103
+ const v = values[i];
104
+ if (
105
+ v &&
106
+ typeof v === 'object' &&
107
+ Object.prototype.hasOwnProperty.call(v, 'raw')
108
+ ) {
109
+ trace(
110
+ 'Utils',
111
+ () =>
112
+ `BRANCH: buildShellCommand => RAW_VALUE | ${JSON.stringify({ value: String(v.raw) }, null, 2)}`
113
+ );
114
+ out += String(v.raw);
115
+ } else {
116
+ const quoted = quote(v);
117
+ trace(
118
+ 'Utils',
119
+ () =>
120
+ `BRANCH: buildShellCommand => QUOTED_VALUE | ${JSON.stringify({ original: v, quoted }, null, 2)}`
121
+ );
122
+ out += quoted;
123
+ }
124
+ }
125
+ }
126
+
127
+ trace(
128
+ 'Utils',
129
+ () =>
130
+ `buildShellCommand EXIT | ${JSON.stringify({ command: out }, null, 2)}`
131
+ );
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Mark a value as raw (not to be quoted)
137
+ * @param {*} value - Value to mark as raw
138
+ * @returns {{ raw: string }} Raw value wrapper
139
+ */
140
+ export function raw(value) {
141
+ trace('API', () => `raw() called with value: ${String(value).slice(0, 50)}`);
142
+ return { raw: String(value) };
143
+ }
144
+
145
+ /**
146
+ * Pump a readable stream, calling onChunk for each chunk
147
+ * @param {Readable} readable - Readable stream
148
+ * @param {function} onChunk - Callback for each chunk
149
+ */
150
+ export async function pumpReadable(readable, onChunk) {
151
+ if (!readable) {
152
+ trace('Utils', () => 'pumpReadable: No readable stream provided');
153
+ return;
154
+ }
155
+ trace('Utils', () => 'pumpReadable: Starting to pump readable stream');
156
+ for await (const chunk of readable) {
157
+ const { asBuffer } = await import('./$.stream-utils.mjs');
158
+ await onChunk(asBuffer(chunk));
159
+ }
160
+ trace('Utils', () => 'pumpReadable: Finished pumping readable stream');
161
+ }
@@ -0,0 +1,23 @@
1
+ // Result creation utilities for command-stream
2
+ // Creates standardized result objects
3
+
4
+ /**
5
+ * Create a standardized result object
6
+ * @param {object} params - Result parameters
7
+ * @param {number} params.code - Exit code
8
+ * @param {string} params.stdout - Standard output
9
+ * @param {string} params.stderr - Standard error
10
+ * @param {string} params.stdin - Standard input that was sent
11
+ * @returns {object} Result object with text() method
12
+ */
13
+ export function createResult({ code, stdout = '', stderr = '', stdin = '' }) {
14
+ return {
15
+ code,
16
+ stdout,
17
+ stderr,
18
+ stdin,
19
+ text() {
20
+ return Promise.resolve(stdout);
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,84 @@
1
+ // Shell settings management (set, unset, shell object)
2
+ // Provides bash-like shell option management
3
+
4
+ import { trace } from './$.trace.mjs';
5
+ import { getShellSettings, setShellSettings } from './$.state.mjs';
6
+
7
+ /**
8
+ * Set a shell option
9
+ * @param {string} option - Option to set
10
+ * @returns {object} Current shell settings
11
+ */
12
+ export function set(option) {
13
+ trace('API', () => `set() called with option: ${option}`);
14
+ const mapping = {
15
+ e: 'errexit', // set -e: exit on error
16
+ errexit: 'errexit',
17
+ v: 'verbose', // set -v: verbose
18
+ verbose: 'verbose',
19
+ x: 'xtrace', // set -x: trace execution
20
+ xtrace: 'xtrace',
21
+ u: 'nounset', // set -u: error on unset vars
22
+ nounset: 'nounset',
23
+ 'o pipefail': 'pipefail', // set -o pipefail
24
+ pipefail: 'pipefail',
25
+ };
26
+
27
+ const globalShellSettings = getShellSettings();
28
+
29
+ if (mapping[option]) {
30
+ setShellSettings({ [mapping[option]]: true });
31
+ if (globalShellSettings.verbose) {
32
+ console.log(`+ set -${option}`);
33
+ }
34
+ }
35
+ return getShellSettings();
36
+ }
37
+
38
+ /**
39
+ * Unset a shell option
40
+ * @param {string} option - Option to unset
41
+ * @returns {object} Current shell settings
42
+ */
43
+ export function unset(option) {
44
+ trace('API', () => `unset() called with option: ${option}`);
45
+ const mapping = {
46
+ e: 'errexit',
47
+ errexit: 'errexit',
48
+ v: 'verbose',
49
+ verbose: 'verbose',
50
+ x: 'xtrace',
51
+ xtrace: 'xtrace',
52
+ u: 'nounset',
53
+ nounset: 'nounset',
54
+ 'o pipefail': 'pipefail',
55
+ pipefail: 'pipefail',
56
+ };
57
+
58
+ const globalShellSettings = getShellSettings();
59
+
60
+ if (mapping[option]) {
61
+ setShellSettings({ [mapping[option]]: false });
62
+ if (globalShellSettings.verbose) {
63
+ console.log(`+ set +${option}`);
64
+ }
65
+ }
66
+ return getShellSettings();
67
+ }
68
+
69
+ /**
70
+ * Convenience object for common shell patterns
71
+ */
72
+ export const shell = {
73
+ set,
74
+ unset,
75
+ settings: () => ({ ...getShellSettings() }),
76
+
77
+ // Bash-like shortcuts
78
+ errexit: (enable = true) => (enable ? set('e') : unset('e')),
79
+ verbose: (enable = true) => (enable ? set('v') : unset('v')),
80
+ xtrace: (enable = true) => (enable ? set('x') : unset('x')),
81
+ pipefail: (enable = true) =>
82
+ enable ? set('o pipefail') : unset('o pipefail'),
83
+ nounset: (enable = true) => (enable ? set('u') : unset('u')),
84
+ };
@@ -0,0 +1,157 @@
1
+ // Shell detection utilities for command-stream
2
+ // Handles cross-platform shell detection and caching
3
+
4
+ import cp from 'child_process';
5
+ import fs from 'fs';
6
+ import { trace } from './$.trace.mjs';
7
+
8
+ // Shell detection cache
9
+ let cachedShell = null;
10
+
11
+ /**
12
+ * Find an available shell by checking multiple options in order
13
+ * Returns the shell command and arguments to use
14
+ * @returns {{ cmd: string, args: string[] }} Shell command and arguments
15
+ */
16
+ export function findAvailableShell() {
17
+ if (cachedShell) {
18
+ trace('ShellDetection', () => `Using cached shell: ${cachedShell.cmd}`);
19
+ return cachedShell;
20
+ }
21
+
22
+ const isWindows = process.platform === 'win32';
23
+
24
+ // Windows-specific shells
25
+ const windowsShells = [
26
+ // Git Bash is the most Unix-compatible option on Windows
27
+ // Check common installation paths
28
+ {
29
+ cmd: 'C:\\Program Files\\Git\\bin\\bash.exe',
30
+ args: ['-c'],
31
+ checkPath: true,
32
+ },
33
+ {
34
+ cmd: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
35
+ args: ['-c'],
36
+ checkPath: true,
37
+ },
38
+ {
39
+ cmd: 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
40
+ args: ['-c'],
41
+ checkPath: true,
42
+ },
43
+ // Git Bash via PATH (if added to PATH by user)
44
+ { cmd: 'bash.exe', args: ['-c'], checkPath: false },
45
+ // WSL bash as fallback
46
+ { cmd: 'wsl.exe', args: ['bash', '-c'], checkPath: false },
47
+ // PowerShell as last resort (different syntax for commands)
48
+ { cmd: 'powershell.exe', args: ['-Command'], checkPath: false },
49
+ { cmd: 'pwsh.exe', args: ['-Command'], checkPath: false },
50
+ // cmd.exe as final fallback
51
+ { cmd: 'cmd.exe', args: ['/c'], checkPath: false },
52
+ ];
53
+
54
+ // Unix-specific shells
55
+ const unixShells = [
56
+ // Try absolute paths first (most reliable)
57
+ { cmd: '/bin/sh', args: ['-l', '-c'], checkPath: true },
58
+ { cmd: '/usr/bin/sh', args: ['-l', '-c'], checkPath: true },
59
+ { cmd: '/bin/bash', args: ['-l', '-c'], checkPath: true },
60
+ { cmd: '/usr/bin/bash', args: ['-l', '-c'], checkPath: true },
61
+ { cmd: '/bin/zsh', args: ['-l', '-c'], checkPath: true },
62
+ { cmd: '/usr/bin/zsh', args: ['-l', '-c'], checkPath: true },
63
+ // macOS specific paths
64
+ { cmd: '/usr/local/bin/bash', args: ['-l', '-c'], checkPath: true },
65
+ { cmd: '/usr/local/bin/zsh', args: ['-l', '-c'], checkPath: true },
66
+ // Linux brew paths
67
+ {
68
+ cmd: '/home/linuxbrew/.linuxbrew/bin/bash',
69
+ args: ['-l', '-c'],
70
+ checkPath: true,
71
+ },
72
+ {
73
+ cmd: '/home/linuxbrew/.linuxbrew/bin/zsh',
74
+ args: ['-l', '-c'],
75
+ checkPath: true,
76
+ },
77
+ // Try shells in PATH as fallback (which might not work in all environments)
78
+ // Using separate -l and -c flags for better compatibility
79
+ { cmd: 'sh', args: ['-l', '-c'], checkPath: false },
80
+ { cmd: 'bash', args: ['-l', '-c'], checkPath: false },
81
+ { cmd: 'zsh', args: ['-l', '-c'], checkPath: false },
82
+ ];
83
+
84
+ // Select shells based on platform
85
+ const shellsToTry = isWindows ? windowsShells : unixShells;
86
+
87
+ for (const shell of shellsToTry) {
88
+ try {
89
+ if (shell.checkPath) {
90
+ // Check if the absolute path exists
91
+ if (fs.existsSync(shell.cmd)) {
92
+ trace(
93
+ 'ShellDetection',
94
+ () => `Found shell at absolute path: ${shell.cmd}`
95
+ );
96
+ cachedShell = { cmd: shell.cmd, args: shell.args };
97
+ return cachedShell;
98
+ }
99
+ } else {
100
+ // On Windows, use 'where' instead of 'which'
101
+ const whichCmd = isWindows ? 'where' : 'which';
102
+ const result = cp.spawnSync(whichCmd, [shell.cmd], {
103
+ encoding: 'utf-8',
104
+ // On Windows, we need shell: true for 'where' to work
105
+ shell: isWindows,
106
+ });
107
+ if (result.status === 0 && result.stdout) {
108
+ const shellPath = result.stdout.trim().split('\n')[0]; // Take first result
109
+ trace(
110
+ 'ShellDetection',
111
+ () => `Found shell in PATH: ${shell.cmd} => ${shellPath}`
112
+ );
113
+ cachedShell = { cmd: shell.cmd, args: shell.args };
114
+ return cachedShell;
115
+ }
116
+ }
117
+ } catch (e) {
118
+ // Continue to next shell option
119
+ trace(
120
+ 'ShellDetection',
121
+ () => `Failed to check shell ${shell.cmd}: ${e.message}`
122
+ );
123
+ }
124
+ }
125
+
126
+ // Final fallback based on platform
127
+ if (isWindows) {
128
+ trace(
129
+ 'ShellDetection',
130
+ () => 'WARNING: No shell found, using cmd.exe as fallback on Windows'
131
+ );
132
+ cachedShell = { cmd: 'cmd.exe', args: ['/c'] };
133
+ } else {
134
+ trace(
135
+ 'ShellDetection',
136
+ () => 'WARNING: No shell found, using /bin/sh as fallback'
137
+ );
138
+ cachedShell = { cmd: '/bin/sh', args: ['-l', '-c'] };
139
+ }
140
+ return cachedShell;
141
+ }
142
+
143
+ /**
144
+ * Clear the shell cache (useful for testing)
145
+ */
146
+ export function clearShellCache() {
147
+ cachedShell = null;
148
+ trace('ShellDetection', () => 'Shell cache cleared');
149
+ }
150
+
151
+ /**
152
+ * Get the currently cached shell (if any)
153
+ * @returns {{ cmd: string, args: string[] } | null}
154
+ */
155
+ export function getCachedShell() {
156
+ return cachedShell;
157
+ }