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,312 @@
1
+ // ProcessRunner stream and kill methods - streaming and process termination
2
+ // Part of the modular ProcessRunner architecture
3
+
4
+ import { trace } from './$.trace.mjs';
5
+ import { createResult } from './$.result.mjs';
6
+
7
+ const isBun = typeof globalThis.Bun !== 'undefined';
8
+
9
+ /**
10
+ * Send a signal to a process and its group
11
+ * @param {number} pid - Process ID
12
+ * @param {string} sig - Signal name (e.g., 'SIGTERM', 'SIGKILL')
13
+ * @param {string} runtime - Runtime identifier for logging
14
+ * @returns {string[]} List of successful operations
15
+ */
16
+ function sendSignalToProcess(pid, sig, runtime) {
17
+ const operations = [];
18
+ const prefix = runtime === 'Bun' ? 'Bun ' : '';
19
+
20
+ try {
21
+ process.kill(pid, sig);
22
+ trace('ProcessRunner', () => `Sent ${sig} to ${prefix}process ${pid}`);
23
+ operations.push(`${sig} to process`);
24
+ } catch (err) {
25
+ trace(
26
+ 'ProcessRunner',
27
+ () => `Error sending ${sig} to ${prefix}process: ${err.message}`
28
+ );
29
+ }
30
+
31
+ try {
32
+ process.kill(-pid, sig);
33
+ trace(
34
+ 'ProcessRunner',
35
+ () => `Sent ${sig} to ${prefix}process group -${pid}`
36
+ );
37
+ operations.push(`${sig} to group`);
38
+ } catch (err) {
39
+ trace(
40
+ 'ProcessRunner',
41
+ () => `${prefix}process group ${sig} failed: ${err.message}`
42
+ );
43
+ }
44
+
45
+ return operations;
46
+ }
47
+
48
+ /**
49
+ * Kill a child process with escalating signals
50
+ * @param {object} child - Child process object
51
+ */
52
+ function killChildProcess(child) {
53
+ if (!child || !child.pid) {
54
+ return;
55
+ }
56
+
57
+ const runtime = isBun ? 'Bun' : 'Node';
58
+ trace(
59
+ 'ProcessRunner',
60
+ () =>
61
+ `Killing ${runtime} process | ${JSON.stringify({ pid: child.pid }, null, 2)}`
62
+ );
63
+
64
+ const killOperations = [];
65
+ killOperations.push(...sendSignalToProcess(child.pid, 'SIGTERM', runtime));
66
+ killOperations.push(...sendSignalToProcess(child.pid, 'SIGKILL', runtime));
67
+
68
+ trace(
69
+ 'ProcessRunner',
70
+ () => `${runtime} kill operations attempted: ${killOperations.join(', ')}`
71
+ );
72
+
73
+ if (isBun) {
74
+ try {
75
+ child.kill();
76
+ trace(
77
+ 'ProcessRunner',
78
+ () => `Called child.kill() for Bun process ${child.pid}`
79
+ );
80
+ } catch (err) {
81
+ trace(
82
+ 'ProcessRunner',
83
+ () => `Error calling child.kill(): ${err.message}`
84
+ );
85
+ }
86
+ }
87
+
88
+ child.removeAllListeners?.();
89
+ }
90
+
91
+ /**
92
+ * Kill pipeline components (source and destination)
93
+ * @param {object} spec - Process runner spec
94
+ * @param {string} signal - Kill signal
95
+ */
96
+ function killPipelineComponents(spec, signal) {
97
+ if (spec?.mode !== 'pipeline') {
98
+ return;
99
+ }
100
+ trace('ProcessRunner', () => 'Killing pipeline components');
101
+ if (spec.source && typeof spec.source.kill === 'function') {
102
+ spec.source.kill(signal);
103
+ }
104
+ if (spec.destination && typeof spec.destination.kill === 'function') {
105
+ spec.destination.kill(signal);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Handle abort controller during kill
111
+ * @param {AbortController} controller - The abort controller
112
+ */
113
+ function abortController(controller) {
114
+ if (!controller) {
115
+ trace('ProcessRunner', () => 'No abort controller to abort');
116
+ return;
117
+ }
118
+ trace(
119
+ 'ProcessRunner',
120
+ () =>
121
+ `Aborting internal controller | ${JSON.stringify({ wasAborted: controller?.signal?.aborted }, null, 2)}`
122
+ );
123
+ controller.abort();
124
+ trace(
125
+ 'ProcessRunner',
126
+ () =>
127
+ `Internal controller aborted | ${JSON.stringify({ nowAborted: controller?.signal?.aborted }, null, 2)}`
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Handle virtual generator cleanup during kill
133
+ * @param {object} generator - The virtual generator
134
+ * @param {string} signal - Kill signal
135
+ */
136
+ function cleanupVirtualGenerator(generator, signal) {
137
+ if (!generator) {
138
+ trace(
139
+ 'ProcessRunner',
140
+ () =>
141
+ `No virtual generator to cleanup | ${JSON.stringify({ hasVirtualGenerator: false }, null, 2)}`
142
+ );
143
+ return;
144
+ }
145
+
146
+ trace(
147
+ 'ProcessRunner',
148
+ () =>
149
+ `Virtual generator found for cleanup | ${JSON.stringify(
150
+ {
151
+ hasReturn: typeof generator.return === 'function',
152
+ hasThrow: typeof generator.throw === 'function',
153
+ signal,
154
+ },
155
+ null,
156
+ 2
157
+ )}`
158
+ );
159
+
160
+ if (generator.return) {
161
+ trace('ProcessRunner', () => 'Closing virtual generator with return()');
162
+ try {
163
+ generator.return();
164
+ trace('ProcessRunner', () => 'Virtual generator closed successfully');
165
+ } catch (err) {
166
+ trace(
167
+ 'ProcessRunner',
168
+ () =>
169
+ `Error closing generator | ${JSON.stringify({ error: err.message, stack: err.stack?.slice(0, 200) }, null, 2)}`
170
+ );
171
+ }
172
+ } else {
173
+ trace('ProcessRunner', () => 'Virtual generator has no return() method');
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Get exit code for signal
179
+ * @param {string} signal - Signal name
180
+ * @returns {number} Exit code
181
+ */
182
+ function getSignalExitCode(signal) {
183
+ if (signal === 'SIGKILL') {
184
+ return 137;
185
+ }
186
+ if (signal === 'SIGTERM') {
187
+ return 143;
188
+ }
189
+ return 130;
190
+ }
191
+
192
+ /**
193
+ * Kill the runner and create result
194
+ * @param {object} runner - ProcessRunner instance
195
+ * @param {string} signal - Kill signal
196
+ */
197
+ function killRunner(runner, signal) {
198
+ runner._cancelled = true;
199
+ runner._cancellationSignal = signal;
200
+ killPipelineComponents(runner.spec, signal);
201
+
202
+ if (runner._cancelResolve) {
203
+ trace('ProcessRunner', () => 'Resolving cancel promise');
204
+ runner._cancelResolve();
205
+ }
206
+
207
+ abortController(runner._abortController);
208
+ cleanupVirtualGenerator(runner._virtualGenerator, signal);
209
+
210
+ if (runner.child && !runner.finished) {
211
+ trace('ProcessRunner', () => `Killing child process ${runner.child.pid}`);
212
+ try {
213
+ killChildProcess(runner.child);
214
+ runner.child = null;
215
+ } catch (err) {
216
+ trace('ProcessRunner', () => `Error killing process: ${err.message}`);
217
+ console.error('Error killing process:', err.message);
218
+ }
219
+ }
220
+
221
+ const result = createResult({
222
+ code: getSignalExitCode(signal),
223
+ stdout: '',
224
+ stderr: `Process killed with ${signal}`,
225
+ stdin: '',
226
+ });
227
+ runner.finish(result);
228
+ }
229
+
230
+ /**
231
+ * Attach stream and kill methods to ProcessRunner prototype
232
+ * @param {Function} ProcessRunner - The ProcessRunner class
233
+ * @param {Object} deps - Dependencies (not used but kept for consistency)
234
+ */
235
+ export function attachStreamKillMethods(ProcessRunner) {
236
+ ProcessRunner.prototype[Symbol.asyncIterator] = async function* () {
237
+ yield* this.stream();
238
+ };
239
+
240
+ ProcessRunner.prototype.stream = async function* () {
241
+ trace('ProcessRunner', () => `stream ENTER | started=${this.started}`);
242
+ this._isStreaming = true;
243
+ if (!this.started) {
244
+ this._startAsync();
245
+ }
246
+
247
+ let buffer = [];
248
+ let resolve, _reject;
249
+ let ended = false;
250
+ let killed = false;
251
+
252
+ const onData = (chunk) => {
253
+ if (!killed) {
254
+ buffer.push(chunk);
255
+ if (resolve) {
256
+ resolve();
257
+ resolve = _reject = null;
258
+ }
259
+ }
260
+ };
261
+
262
+ const onEnd = () => {
263
+ ended = true;
264
+ if (resolve) {
265
+ resolve();
266
+ resolve = _reject = null;
267
+ }
268
+ };
269
+
270
+ this.on('data', onData);
271
+ this.on('end', onEnd);
272
+
273
+ try {
274
+ while (!ended || buffer.length > 0) {
275
+ if (killed) {
276
+ break;
277
+ }
278
+ if (buffer.length > 0) {
279
+ const chunk = buffer.shift();
280
+ this._streamYielding = true;
281
+ yield chunk;
282
+ this._streamYielding = false;
283
+ } else if (!ended) {
284
+ await new Promise((res, rej) => {
285
+ resolve = res;
286
+ _reject = rej;
287
+ });
288
+ }
289
+ }
290
+ } finally {
291
+ this.off('data', onData);
292
+ this.off('end', onEnd);
293
+ if (!this.finished) {
294
+ killed = true;
295
+ buffer = [];
296
+ this._streamBreaking = true;
297
+ this.kill();
298
+ }
299
+ }
300
+ };
301
+
302
+ ProcessRunner.prototype.kill = function (signal = 'SIGTERM') {
303
+ trace(
304
+ 'ProcessRunner',
305
+ () => `kill | signal=${signal} finished=${this.finished}`
306
+ );
307
+ if (this.finished) {
308
+ return;
309
+ }
310
+ killRunner(this, signal);
311
+ };
312
+ }
@@ -0,0 +1,297 @@
1
+ // ProcessRunner virtual command methods - virtual command execution
2
+ // Part of the modular ProcessRunner architecture
3
+
4
+ import { trace } from './$.trace.mjs';
5
+ import { safeWrite } from './$.stream-utils.mjs';
6
+
7
+ /**
8
+ * Get stdin data from options
9
+ * @param {object} options - Runner options
10
+ * @returns {string} Stdin data
11
+ */
12
+ function getStdinData(options) {
13
+ if (options.stdin && typeof options.stdin === 'string') {
14
+ return options.stdin;
15
+ }
16
+ if (options.stdin && Buffer.isBuffer(options.stdin)) {
17
+ return options.stdin.toString('utf8');
18
+ }
19
+ return '';
20
+ }
21
+
22
+ /**
23
+ * Get exit code for cancellation signal
24
+ * @param {string} signal - Cancellation signal
25
+ * @returns {number} Exit code
26
+ */
27
+ function getCancellationExitCode(signal) {
28
+ if (signal === 'SIGINT') {
29
+ return 130;
30
+ }
31
+ if (signal === 'SIGTERM') {
32
+ return 143;
33
+ }
34
+ return 1;
35
+ }
36
+
37
+ /**
38
+ * Create abort promise for non-generator handlers
39
+ * @param {AbortController} abortController - Abort controller
40
+ * @returns {Promise} Promise that rejects on abort
41
+ */
42
+ function createAbortPromise(abortController) {
43
+ return new Promise((_, reject) => {
44
+ if (abortController && abortController.signal.aborted) {
45
+ reject(new Error('Command cancelled'));
46
+ }
47
+ if (abortController) {
48
+ abortController.signal.addEventListener('abort', () => {
49
+ reject(new Error('Command cancelled'));
50
+ });
51
+ }
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Emit output and mirror if needed
57
+ * @param {object} runner - ProcessRunner instance
58
+ * @param {string} type - Output type (stdout/stderr)
59
+ * @param {string} data - Output data
60
+ */
61
+ function emitOutput(runner, type, data) {
62
+ if (!data) {
63
+ return;
64
+ }
65
+ const buf = Buffer.from(data);
66
+ const stream = type === 'stdout' ? process.stdout : process.stderr;
67
+ if (runner.options.mirror) {
68
+ safeWrite(stream, buf);
69
+ }
70
+ runner._emitProcessedData(type, buf);
71
+ }
72
+
73
+ /**
74
+ * Handle error in virtual command
75
+ * @param {object} runner - ProcessRunner instance
76
+ * @param {Error} error - Error that occurred
77
+ * @param {object} shellSettings - Global shell settings
78
+ * @returns {object} Result object
79
+ */
80
+ function handleVirtualError(runner, error, shellSettings) {
81
+ let exitCode = error.code ?? 1;
82
+ if (runner._cancelled && runner._cancellationSignal) {
83
+ exitCode = getCancellationExitCode(runner._cancellationSignal);
84
+ }
85
+
86
+ const result = {
87
+ code: exitCode,
88
+ stdout: error.stdout ?? '',
89
+ stderr: error.stderr ?? error.message,
90
+ stdin: '',
91
+ };
92
+
93
+ emitOutput(runner, 'stderr', result.stderr);
94
+ runner.finish(result);
95
+
96
+ if (shellSettings.errexit) {
97
+ error.result = result;
98
+ throw error;
99
+ }
100
+
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * Run async generator handler
106
+ * @param {object} runner - ProcessRunner instance
107
+ * @param {Function} handler - Generator handler
108
+ * @param {Array} argValues - Argument values
109
+ * @param {string} stdinData - Stdin data
110
+ * @returns {Promise<object>} Result object
111
+ */
112
+ async function runGeneratorHandler(runner, handler, argValues, stdinData) {
113
+ const chunks = [];
114
+ const commandOptions = {
115
+ cwd: runner.options.cwd,
116
+ env: runner.options.env,
117
+ options: runner.options,
118
+ isCancelled: () => runner._cancelled,
119
+ };
120
+
121
+ const generator = handler({
122
+ args: argValues,
123
+ stdin: stdinData,
124
+ abortSignal: runner._abortController?.signal,
125
+ ...commandOptions,
126
+ });
127
+ runner._virtualGenerator = generator;
128
+
129
+ const cancelPromise = new Promise((resolve) => {
130
+ runner._cancelResolve = resolve;
131
+ });
132
+
133
+ try {
134
+ const iterator = generator[Symbol.asyncIterator]();
135
+ let done = false;
136
+
137
+ while (!done && !runner._cancelled) {
138
+ const result = await Promise.race([
139
+ iterator.next(),
140
+ cancelPromise.then(() => ({ done: true, cancelled: true })),
141
+ ]);
142
+
143
+ if (result.cancelled || runner._cancelled) {
144
+ if (iterator.return) {
145
+ await iterator.return();
146
+ }
147
+ break;
148
+ }
149
+
150
+ done = result.done;
151
+
152
+ if (!done && !runner._cancelled && !runner._streamBreaking) {
153
+ const buf = Buffer.from(result.value);
154
+ chunks.push(buf);
155
+
156
+ if (runner.options.mirror) {
157
+ safeWrite(process.stdout, buf);
158
+ }
159
+ runner._emitProcessedData('stdout', buf);
160
+ }
161
+ }
162
+ } finally {
163
+ runner._virtualGenerator = null;
164
+ runner._cancelResolve = null;
165
+ }
166
+
167
+ return {
168
+ code: 0,
169
+ stdout: runner.options.capture
170
+ ? Buffer.concat(chunks).toString('utf8')
171
+ : undefined,
172
+ stderr: runner.options.capture ? '' : undefined,
173
+ stdin: runner.options.capture ? stdinData : undefined,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Run regular (non-generator) handler
179
+ * @param {object} runner - ProcessRunner instance
180
+ * @param {Function} handler - Handler function
181
+ * @param {Array} argValues - Argument values
182
+ * @param {string} stdinData - Stdin data
183
+ * @returns {Promise<object>} Result object
184
+ */
185
+ async function runRegularHandler(runner, handler, argValues, stdinData) {
186
+ const commandOptions = {
187
+ cwd: runner.options.cwd,
188
+ env: runner.options.env,
189
+ options: runner.options,
190
+ isCancelled: () => runner._cancelled,
191
+ };
192
+
193
+ const handlerPromise = handler({
194
+ args: argValues,
195
+ stdin: stdinData,
196
+ abortSignal: runner._abortController?.signal,
197
+ ...commandOptions,
198
+ });
199
+
200
+ const abortPromise = createAbortPromise(runner._abortController);
201
+ let result;
202
+
203
+ try {
204
+ result = await Promise.race([handlerPromise, abortPromise]);
205
+ } catch (err) {
206
+ if (err.message === 'Command cancelled') {
207
+ const exitCode = getCancellationExitCode(runner._cancellationSignal);
208
+ result = { code: exitCode, stdout: '', stderr: '' };
209
+ } else {
210
+ throw err;
211
+ }
212
+ }
213
+
214
+ result = {
215
+ ...result,
216
+ code: result.code ?? 0,
217
+ stdout: runner.options.capture ? (result.stdout ?? '') : undefined,
218
+ stderr: runner.options.capture ? (result.stderr ?? '') : undefined,
219
+ stdin: runner.options.capture ? stdinData : undefined,
220
+ };
221
+
222
+ emitOutput(runner, 'stdout', result.stdout);
223
+ emitOutput(runner, 'stderr', result.stderr);
224
+
225
+ return result;
226
+ }
227
+
228
+ /**
229
+ * Attach virtual command methods to ProcessRunner prototype
230
+ * @param {Function} ProcessRunner - The ProcessRunner class
231
+ * @param {Object} deps - Dependencies
232
+ */
233
+ export function attachVirtualCommandMethods(ProcessRunner, deps) {
234
+ const { virtualCommands, globalShellSettings } = deps;
235
+
236
+ ProcessRunner.prototype._runVirtual = async function (
237
+ cmd,
238
+ args,
239
+ originalCommand = null
240
+ ) {
241
+ trace('ProcessRunner', () => `_runVirtual | cmd=${cmd}`);
242
+
243
+ const handler = virtualCommands.get(cmd);
244
+ if (!handler) {
245
+ throw new Error(`Virtual command not found: ${cmd}`);
246
+ }
247
+
248
+ try {
249
+ // Special handling for streaming mode (stdin: "pipe")
250
+ if (this.options.stdin === 'pipe') {
251
+ const modifiedOptions = {
252
+ ...this.options,
253
+ stdin: 'pipe',
254
+ _bypassVirtual: true,
255
+ };
256
+ const ProcessRunnerRef = this.constructor;
257
+ const realRunner = new ProcessRunnerRef(
258
+ { mode: 'shell', command: originalCommand || cmd },
259
+ modifiedOptions
260
+ );
261
+ return await realRunner._doStartAsync();
262
+ }
263
+
264
+ const stdinData = getStdinData(this.options);
265
+ const argValues = args.map((arg) =>
266
+ arg.value !== undefined ? arg.value : arg
267
+ );
268
+
269
+ if (globalShellSettings.xtrace) {
270
+ console.log(`+ ${originalCommand || `${cmd} ${argValues.join(' ')}`}`);
271
+ }
272
+ if (globalShellSettings.verbose) {
273
+ console.log(`${originalCommand || `${cmd} ${argValues.join(' ')}`}`);
274
+ }
275
+
276
+ const isGenerator = handler.constructor.name === 'AsyncGeneratorFunction';
277
+ const result = isGenerator
278
+ ? await runGeneratorHandler(this, handler, argValues, stdinData)
279
+ : await runRegularHandler(this, handler, argValues, stdinData);
280
+
281
+ this.finish(result);
282
+
283
+ if (globalShellSettings.errexit && result.code !== 0) {
284
+ const error = new Error(`Command failed with exit code ${result.code}`);
285
+ error.code = result.code;
286
+ error.stdout = result.stdout;
287
+ error.stderr = result.stderr;
288
+ error.result = result;
289
+ throw error;
290
+ }
291
+
292
+ return result;
293
+ } catch (error) {
294
+ return handleVirtualError(this, error, globalShellSettings);
295
+ }
296
+ };
297
+ }