@push.rocks/smartshell 3.2.3 → 3.3.0

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.
@@ -8,6 +8,15 @@ import * as cp from 'child_process';
8
8
  export interface IExecResult {
9
9
  exitCode: number;
10
10
  stdout: string;
11
+ signal?: NodeJS.Signals;
12
+ stderr?: string;
13
+ }
14
+
15
+ export interface IExecResultInteractive extends IExecResult {
16
+ sendInput: (input: string) => Promise<void>;
17
+ sendLine: (line: string) => Promise<void>;
18
+ endInput: () => void;
19
+ finalPromise: Promise<IExecResult>;
11
20
  }
12
21
 
13
22
  export interface IExecResultStreaming {
@@ -17,6 +26,9 @@ export interface IExecResultStreaming {
17
26
  terminate: () => Promise<void>;
18
27
  keyboardInterrupt: () => Promise<void>;
19
28
  customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise<void>;
29
+ sendInput: (input: string) => Promise<void>;
30
+ sendLine: (line: string) => Promise<void>;
31
+ endInput: () => void;
20
32
  }
21
33
 
22
34
  interface IExecOptions {
@@ -25,6 +37,24 @@ interface IExecOptions {
25
37
  strict?: boolean;
26
38
  streaming?: boolean;
27
39
  interactive?: boolean;
40
+ passthrough?: boolean;
41
+ interactiveControl?: boolean;
42
+ usePty?: boolean;
43
+ ptyCols?: number;
44
+ ptyRows?: number;
45
+ ptyTerm?: string;
46
+ ptyShell?: string;
47
+ maxBuffer?: number;
48
+ onData?: (chunk: Buffer | string) => void;
49
+ timeout?: number;
50
+ debug?: boolean;
51
+ env?: NodeJS.ProcessEnv;
52
+ signal?: AbortSignal;
53
+ }
54
+
55
+ export interface ISpawnOptions extends Omit<IExecOptions, 'commandString'> {
56
+ command: string;
57
+ args?: string[];
28
58
  }
29
59
 
30
60
  export class Smartshell {
@@ -71,79 +101,442 @@ export class Smartshell {
71
101
  });
72
102
  }
73
103
 
104
+ /**
105
+ * Executes a command with args array (shell:false) for security
106
+ */
107
+ private async _execSpawn(options: ISpawnOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
108
+ const shellLogInstance = new ShellLog();
109
+ let stderrBuffer = '';
110
+ const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
111
+ let bufferExceeded = false;
112
+
113
+ // Handle PTY mode if requested
114
+ if (options.usePty) {
115
+ throw new Error('PTY mode is not yet supported with execSpawn. Use exec methods with shell:true for PTY.');
116
+ }
117
+
118
+ const execChildProcess = cp.spawn(options.command, options.args || [], {
119
+ shell: false, // SECURITY: Never use shell with untrusted input
120
+ cwd: process.cwd(),
121
+ env: options.env || process.env,
122
+ detached: false,
123
+ signal: options.signal,
124
+ });
125
+
126
+ this.smartexit.addProcess(execChildProcess);
127
+
128
+ // Handle timeout
129
+ let timeoutHandle: NodeJS.Timeout | null = null;
130
+ if (options.timeout) {
131
+ timeoutHandle = setTimeout(() => {
132
+ if (options.debug) {
133
+ console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
134
+ }
135
+ execChildProcess.kill('SIGTERM');
136
+ }, options.timeout);
137
+ }
138
+
139
+ // Connect stdin if passthrough is enabled (but not for interactive control)
140
+ if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
141
+ process.stdin.pipe(execChildProcess.stdin);
142
+ }
143
+
144
+ // Create input methods for interactive control
145
+ const sendInput = async (input: string): Promise<void> => {
146
+ if (!execChildProcess.stdin) {
147
+ throw new Error('stdin is not available for this process');
148
+ }
149
+ if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
150
+ throw new Error('stdin has been destroyed or is not writable');
151
+ }
152
+ return new Promise((resolve, reject) => {
153
+ execChildProcess.stdin.write(input, 'utf8', (error) => {
154
+ if (error) {
155
+ reject(error);
156
+ } else {
157
+ resolve();
158
+ }
159
+ });
160
+ });
161
+ };
162
+
163
+ const sendLine = async (line: string): Promise<void> => {
164
+ return sendInput(line + '\n');
165
+ };
166
+
167
+ const endInput = (): void => {
168
+ if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
169
+ execChildProcess.stdin.end();
170
+ }
171
+ };
172
+
173
+ // Capture stdout and stderr output
174
+ execChildProcess.stdout.on('data', (data) => {
175
+ if (!options.silent) {
176
+ shellLogInstance.writeToConsole(data);
177
+ }
178
+
179
+ if (options.onData) {
180
+ options.onData(data);
181
+ }
182
+
183
+ if (!bufferExceeded) {
184
+ shellLogInstance.addToBuffer(data);
185
+ if (shellLogInstance.logStore.length > maxBuffer) {
186
+ bufferExceeded = true;
187
+ shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
188
+ }
189
+ }
190
+ });
191
+
192
+ execChildProcess.stderr.on('data', (data) => {
193
+ if (!options.silent) {
194
+ shellLogInstance.writeToConsole(data);
195
+ }
196
+
197
+ const dataStr = data.toString();
198
+ stderrBuffer += dataStr;
199
+
200
+ if (options.onData) {
201
+ options.onData(data);
202
+ }
203
+
204
+ if (!bufferExceeded) {
205
+ shellLogInstance.addToBuffer(data);
206
+ if (shellLogInstance.logStore.length > maxBuffer) {
207
+ bufferExceeded = true;
208
+ shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
209
+ }
210
+ }
211
+ });
212
+
213
+ // Wrap child process termination into a Promise
214
+ const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
215
+ const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
216
+ if (timeoutHandle) {
217
+ clearTimeout(timeoutHandle);
218
+ }
219
+ this.smartexit.removeProcess(execChildProcess);
220
+
221
+ // Safely unpipe stdin when process ends if passthrough was enabled
222
+ if (options.passthrough && !options.interactiveControl) {
223
+ try {
224
+ if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
225
+ process.stdin.unpipe(execChildProcess.stdin);
226
+ }
227
+ } catch (err) {
228
+ if (options.debug) {
229
+ console.log(`[smartshell] Error unpiping stdin: ${err}`);
230
+ }
231
+ }
232
+ }
233
+
234
+ const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
235
+ const execResult: IExecResult = {
236
+ exitCode,
237
+ stdout: shellLogInstance.logStore.toString(),
238
+ signal: signal || undefined,
239
+ stderr: stderrBuffer,
240
+ };
241
+
242
+ if (options.strict && exitCode !== 0) {
243
+ const errorMsg = signal
244
+ ? `Command "${options.command}" terminated by signal ${signal}`
245
+ : `Command "${options.command}" exited with code ${exitCode}`;
246
+ reject(new Error(errorMsg));
247
+ } else {
248
+ resolve(execResult);
249
+ }
250
+ };
251
+
252
+ execChildProcess.once('exit', handleExit);
253
+ execChildProcess.once('error', (error) => {
254
+ if (timeoutHandle) {
255
+ clearTimeout(timeoutHandle);
256
+ }
257
+ this.smartexit.removeProcess(execChildProcess);
258
+
259
+ // Safely unpipe stdin when process errors if passthrough was enabled
260
+ if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
261
+ try {
262
+ if (!execChildProcess.stdin.destroyed) {
263
+ process.stdin.unpipe(execChildProcess.stdin);
264
+ }
265
+ } catch (err) {
266
+ if (options.debug) {
267
+ console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
268
+ }
269
+ }
270
+ }
271
+ reject(error);
272
+ });
273
+ });
274
+
275
+ // If interactive control is enabled but not streaming, return interactive interface
276
+ if (options.interactiveControl && !options.streaming) {
277
+ return {
278
+ exitCode: 0, // Will be updated when process ends
279
+ stdout: '', // Will be updated when process ends
280
+ sendInput,
281
+ sendLine,
282
+ endInput,
283
+ finalPromise: childProcessEnded,
284
+ } as IExecResultInteractive;
285
+ }
286
+
287
+ // If streaming mode is enabled, return a streaming interface
288
+ if (options.streaming) {
289
+ return {
290
+ childProcess: execChildProcess,
291
+ finalPromise: childProcessEnded,
292
+ sendInput,
293
+ sendLine,
294
+ endInput,
295
+ kill: async () => {
296
+ if (options.debug) {
297
+ console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
298
+ }
299
+ await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
300
+ },
301
+ terminate: async () => {
302
+ if (options.debug) {
303
+ console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
304
+ }
305
+ await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
306
+ },
307
+ keyboardInterrupt: async () => {
308
+ if (options.debug) {
309
+ console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
310
+ }
311
+ await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
312
+ },
313
+ customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
314
+ if (options.debug) {
315
+ console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
316
+ }
317
+ await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
318
+ },
319
+ } as IExecResultStreaming;
320
+ }
321
+
322
+ // For non-streaming mode, wait for the process to complete
323
+ return await childProcessEnded;
324
+ }
325
+
74
326
  /**
75
327
  * Executes a command and returns either a non-streaming result or a streaming interface.
76
328
  */
77
- private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming> {
329
+ private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
78
330
  const commandToExecute = this.shellEnv.createEnvExecString(options.commandString);
79
331
  const shellLogInstance = new ShellLog();
332
+ let stderrBuffer = '';
333
+ const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
334
+ let bufferExceeded = false;
335
+
336
+ // Handle PTY mode if requested
337
+ if (options.usePty) {
338
+ return await this._execCommandPty(options, commandToExecute, shellLogInstance);
339
+ }
80
340
 
81
341
  const execChildProcess = cp.spawn(commandToExecute, [], {
82
342
  shell: true,
83
343
  cwd: process.cwd(),
84
- env: process.env,
344
+ env: options.env || process.env,
85
345
  detached: false,
346
+ signal: options.signal,
86
347
  });
87
348
 
88
349
  this.smartexit.addProcess(execChildProcess);
89
350
 
90
- // Capture stdout and stderr output.
351
+ // Handle timeout
352
+ let timeoutHandle: NodeJS.Timeout | null = null;
353
+ if (options.timeout) {
354
+ timeoutHandle = setTimeout(() => {
355
+ if (options.debug) {
356
+ console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
357
+ }
358
+ execChildProcess.kill('SIGTERM');
359
+ }, options.timeout);
360
+ }
361
+
362
+ // Connect stdin if passthrough is enabled (but not for interactive control)
363
+ if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
364
+ process.stdin.pipe(execChildProcess.stdin);
365
+ }
366
+
367
+ // Create input methods for interactive control
368
+ const sendInput = async (input: string): Promise<void> => {
369
+ if (!execChildProcess.stdin) {
370
+ throw new Error('stdin is not available for this process');
371
+ }
372
+ if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
373
+ throw new Error('stdin has been destroyed or is not writable');
374
+ }
375
+ return new Promise((resolve, reject) => {
376
+ execChildProcess.stdin.write(input, 'utf8', (error) => {
377
+ if (error) {
378
+ reject(error);
379
+ } else {
380
+ resolve();
381
+ }
382
+ });
383
+ });
384
+ };
385
+
386
+ const sendLine = async (line: string): Promise<void> => {
387
+ return sendInput(line + '\n');
388
+ };
389
+
390
+ const endInput = (): void => {
391
+ if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
392
+ execChildProcess.stdin.end();
393
+ }
394
+ };
395
+
396
+ // Capture stdout and stderr output
91
397
  execChildProcess.stdout.on('data', (data) => {
92
398
  if (!options.silent) {
93
399
  shellLogInstance.writeToConsole(data);
94
400
  }
95
- shellLogInstance.addToBuffer(data);
401
+
402
+ if (options.onData) {
403
+ options.onData(data);
404
+ }
405
+
406
+ if (!bufferExceeded) {
407
+ shellLogInstance.addToBuffer(data);
408
+ if (shellLogInstance.logStore.length > maxBuffer) {
409
+ bufferExceeded = true;
410
+ shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
411
+ }
412
+ }
96
413
  });
97
414
 
98
415
  execChildProcess.stderr.on('data', (data) => {
99
416
  if (!options.silent) {
100
417
  shellLogInstance.writeToConsole(data);
101
418
  }
102
- shellLogInstance.addToBuffer(data);
419
+
420
+ const dataStr = data.toString();
421
+ stderrBuffer += dataStr;
422
+
423
+ if (options.onData) {
424
+ options.onData(data);
425
+ }
426
+
427
+ if (!bufferExceeded) {
428
+ shellLogInstance.addToBuffer(data);
429
+ if (shellLogInstance.logStore.length > maxBuffer) {
430
+ bufferExceeded = true;
431
+ shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
432
+ }
433
+ }
103
434
  });
104
435
 
105
- // Wrap child process termination into a Promise.
436
+ // Wrap child process termination into a Promise
106
437
  const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
107
- execChildProcess.on('exit', (code, signal) => {
438
+ const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
439
+ if (timeoutHandle) {
440
+ clearTimeout(timeoutHandle);
441
+ }
108
442
  this.smartexit.removeProcess(execChildProcess);
109
443
 
444
+ // Safely unpipe stdin when process ends if passthrough was enabled
445
+ if (options.passthrough && !options.interactiveControl) {
446
+ try {
447
+ if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
448
+ process.stdin.unpipe(execChildProcess.stdin);
449
+ }
450
+ } catch (err) {
451
+ if (options.debug) {
452
+ console.log(`[smartshell] Error unpiping stdin: ${err}`);
453
+ }
454
+ }
455
+ }
456
+
457
+ const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
110
458
  const execResult: IExecResult = {
111
- exitCode: typeof code === 'number' ? code : (signal ? 1 : 0),
459
+ exitCode,
112
460
  stdout: shellLogInstance.logStore.toString(),
461
+ signal: signal || undefined,
462
+ stderr: stderrBuffer,
113
463
  };
114
464
 
115
- if (options.strict && code !== 0) {
116
- reject(new Error(`Command "${options.commandString}" exited with code ${code}`));
465
+ if (options.strict && exitCode !== 0) {
466
+ const errorMsg = signal
467
+ ? `Command "${options.commandString}" terminated by signal ${signal}`
468
+ : `Command "${options.commandString}" exited with code ${exitCode}`;
469
+ reject(new Error(errorMsg));
117
470
  } else {
118
471
  resolve(execResult);
119
472
  }
120
- });
473
+ };
121
474
 
122
- execChildProcess.on('error', (error) => {
475
+ execChildProcess.once('exit', handleExit);
476
+ execChildProcess.once('error', (error) => {
477
+ if (timeoutHandle) {
478
+ clearTimeout(timeoutHandle);
479
+ }
123
480
  this.smartexit.removeProcess(execChildProcess);
481
+
482
+ // Safely unpipe stdin when process errors if passthrough was enabled
483
+ if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
484
+ try {
485
+ if (!execChildProcess.stdin.destroyed) {
486
+ process.stdin.unpipe(execChildProcess.stdin);
487
+ }
488
+ } catch (err) {
489
+ if (options.debug) {
490
+ console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
491
+ }
492
+ }
493
+ }
124
494
  reject(error);
125
495
  });
126
496
  });
127
497
 
128
- // If streaming mode is enabled, return a streaming interface immediately.
498
+ // If interactive control is enabled but not streaming, return interactive interface
499
+ if (options.interactiveControl && !options.streaming) {
500
+ return {
501
+ exitCode: 0, // Will be updated when process ends
502
+ stdout: '', // Will be updated when process ends
503
+ sendInput,
504
+ sendLine,
505
+ endInput,
506
+ finalPromise: childProcessEnded,
507
+ } as IExecResultInteractive;
508
+ }
509
+
510
+ // If streaming mode is enabled, return a streaming interface
129
511
  if (options.streaming) {
130
512
  return {
131
513
  childProcess: execChildProcess,
132
514
  finalPromise: childProcessEnded,
515
+ sendInput,
516
+ sendLine,
517
+ endInput,
133
518
  kill: async () => {
134
- console.log(`Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
519
+ if (options.debug) {
520
+ console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
521
+ }
135
522
  await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
136
523
  },
137
524
  terminate: async () => {
138
- console.log(`Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
525
+ if (options.debug) {
526
+ console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
527
+ }
139
528
  await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
140
529
  },
141
530
  keyboardInterrupt: async () => {
142
- console.log(`Running tree kill with SIGINT on process ${execChildProcess.pid}`);
531
+ if (options.debug) {
532
+ console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
533
+ }
143
534
  await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
144
535
  },
145
536
  customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
146
- console.log(`Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
537
+ if (options.debug) {
538
+ console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
539
+ }
147
540
  await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
148
541
  },
149
542
  } as IExecResultStreaming;
@@ -154,7 +547,9 @@ export class Smartshell {
154
547
  }
155
548
 
156
549
  public async exec(commandString: string): Promise<IExecResult> {
157
- return (await this._exec({ commandString })) as IExecResult;
550
+ const result = await this._exec({ commandString });
551
+ // Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
552
+ return result as IExecResult;
158
553
  }
159
554
 
160
555
  public async execSilent(commandString: string): Promise<IExecResult> {
@@ -181,23 +576,298 @@ export class Smartshell {
181
576
  await this._exec({ commandString, interactive: true });
182
577
  }
183
578
 
579
+ public async execPassthrough(commandString: string): Promise<IExecResult> {
580
+ return await this._exec({ commandString, passthrough: true }) as IExecResult;
581
+ }
582
+
583
+ public async execStreamingPassthrough(commandString: string): Promise<IExecResultStreaming> {
584
+ return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming;
585
+ }
586
+
587
+ public async execInteractiveControl(commandString: string): Promise<IExecResultInteractive> {
588
+ return await this._exec({ commandString, interactiveControl: true }) as IExecResultInteractive;
589
+ }
590
+
591
+ public async execStreamingInteractiveControl(commandString: string): Promise<IExecResultStreaming> {
592
+ return await this._exec({ commandString, streaming: true, interactiveControl: true }) as IExecResultStreaming;
593
+ }
594
+
595
+ public async execInteractiveControlPty(commandString: string): Promise<IExecResultInteractive> {
596
+ return await this._exec({ commandString, interactiveControl: true, usePty: true }) as IExecResultInteractive;
597
+ }
598
+
599
+ public async execStreamingInteractiveControlPty(commandString: string): Promise<IExecResultStreaming> {
600
+ return await this._exec({ commandString, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming;
601
+ }
602
+
603
+ /**
604
+ * Executes a command with args array (shell:false) for security
605
+ * This is the recommended API for untrusted input
606
+ */
607
+ public async execSpawn(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args'> = {}): Promise<IExecResult> {
608
+ const result = await this._execSpawn({ command, args, ...options });
609
+ // Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
610
+ return result as IExecResult;
611
+ }
612
+
613
+ /**
614
+ * Executes a command with args array in streaming mode
615
+ */
616
+ public async execSpawnStreaming(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'streaming'> = {}): Promise<IExecResultStreaming> {
617
+ return await this._execSpawn({ command, args, streaming: true, ...options }) as IExecResultStreaming;
618
+ }
619
+
620
+ /**
621
+ * Executes a command with args array with interactive control
622
+ */
623
+ public async execSpawnInteractiveControl(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'interactiveControl'> = {}): Promise<IExecResultInteractive> {
624
+ return await this._execSpawn({ command, args, interactiveControl: true, ...options }) as IExecResultInteractive;
625
+ }
626
+
184
627
  public async execAndWaitForLine(
185
628
  commandString: string,
186
629
  regex: RegExp,
187
- silent: boolean = false
630
+ silent: boolean = false,
631
+ options: { timeout?: number; terminateOnMatch?: boolean } = {}
188
632
  ): Promise<void> {
189
633
  const execStreamingResult = await this.execStreaming(commandString, silent);
190
- return new Promise<void>((resolve) => {
191
- execStreamingResult.childProcess.stdout.on('data', (chunk: Buffer | string) => {
634
+
635
+ return new Promise<void>((resolve, reject) => {
636
+ let matched = false;
637
+ let timeoutHandle: NodeJS.Timeout | null = null;
638
+
639
+ // Set up timeout if specified
640
+ if (options.timeout) {
641
+ timeoutHandle = setTimeout(async () => {
642
+ if (!matched) {
643
+ matched = true;
644
+ // Remove listener to prevent memory leak
645
+ execStreamingResult.childProcess.stdout.removeAllListeners('data');
646
+ await execStreamingResult.terminate();
647
+ reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`));
648
+ }
649
+ }, options.timeout);
650
+ }
651
+
652
+ const dataHandler = async (chunk: Buffer | string) => {
192
653
  const data = typeof chunk === 'string' ? chunk : chunk.toString();
193
- if (regex.test(data)) {
654
+ if (!matched && regex.test(data)) {
655
+ matched = true;
656
+
657
+ // Clear timeout if set
658
+ if (timeoutHandle) {
659
+ clearTimeout(timeoutHandle);
660
+ }
661
+
662
+ // Remove listener to prevent memory leak
663
+ execStreamingResult.childProcess.stdout.removeListener('data', dataHandler);
664
+
665
+ // Terminate process if requested
666
+ if (options.terminateOnMatch) {
667
+ await execStreamingResult.terminate();
668
+ await execStreamingResult.finalPromise;
669
+ }
670
+
194
671
  resolve();
195
672
  }
673
+ };
674
+
675
+ execStreamingResult.childProcess.stdout.on('data', dataHandler);
676
+
677
+ // Also resolve/reject when process ends
678
+ execStreamingResult.finalPromise.then(() => {
679
+ if (!matched) {
680
+ matched = true;
681
+ if (timeoutHandle) {
682
+ clearTimeout(timeoutHandle);
683
+ }
684
+ reject(new Error('Process ended without matching pattern'));
685
+ }
686
+ }).catch((err) => {
687
+ if (!matched) {
688
+ matched = true;
689
+ if (timeoutHandle) {
690
+ clearTimeout(timeoutHandle);
691
+ }
692
+ reject(err);
693
+ }
196
694
  });
197
695
  });
198
696
  }
199
697
 
200
- public async execAndWaitForLineSilent(commandString: string, regex: RegExp): Promise<void> {
201
- return this.execAndWaitForLine(commandString, regex, true);
698
+ public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise<void> {
699
+ return this.execAndWaitForLine(commandString, regex, true, options);
202
700
  }
203
- }
701
+
702
+ private nodePty: any = null;
703
+
704
+ private async lazyLoadNodePty(): Promise<any> {
705
+ if (this.nodePty) {
706
+ return this.nodePty;
707
+ }
708
+
709
+ try {
710
+ // Try to load node-pty if available
711
+ // @ts-ignore - node-pty is optional
712
+ this.nodePty = await import('node-pty');
713
+ return this.nodePty;
714
+ } catch (error) {
715
+ throw new Error(
716
+ 'node-pty is required for PTY support but is not installed.\n' +
717
+ 'Please install it as an optional dependency:\n' +
718
+ ' pnpm add --save-optional node-pty\n' +
719
+ 'Note: node-pty requires compilation and may have platform-specific requirements.'
720
+ );
721
+ }
722
+ }
723
+
724
+ private async _execCommandPty(
725
+ options: IExecOptions,
726
+ commandToExecute: string,
727
+ shellLogInstance: ShellLog
728
+ ): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
729
+ const pty = await this.lazyLoadNodePty();
730
+
731
+ // Platform-aware shell selection
732
+ let shell: string;
733
+ let shellArgs: string[];
734
+
735
+ if (options.ptyShell) {
736
+ // User-provided shell override
737
+ shell = options.ptyShell;
738
+ shellArgs = ['-c', commandToExecute];
739
+ } else if (process.platform === 'win32') {
740
+ // Windows: Use PowerShell by default, or cmd as fallback
741
+ const powershell = process.env.PROGRAMFILES
742
+ ? `${process.env.PROGRAMFILES}\\PowerShell\\7\\pwsh.exe`
743
+ : 'powershell.exe';
744
+
745
+ // Check if PowerShell Core exists, otherwise use Windows PowerShell
746
+ const fs = await import('fs');
747
+ if (fs.existsSync(powershell)) {
748
+ shell = powershell;
749
+ shellArgs = ['-NoProfile', '-NonInteractive', '-Command', commandToExecute];
750
+ } else if (process.env.COMSPEC) {
751
+ shell = process.env.COMSPEC;
752
+ shellArgs = ['/d', '/s', '/c', commandToExecute];
753
+ } else {
754
+ shell = 'cmd.exe';
755
+ shellArgs = ['/d', '/s', '/c', commandToExecute];
756
+ }
757
+ } else {
758
+ // POSIX: Use SHELL env var or bash as default
759
+ shell = process.env.SHELL || '/bin/bash';
760
+ shellArgs = ['-c', commandToExecute];
761
+ }
762
+
763
+ // Create PTY process
764
+ const ptyProcess = pty.spawn(shell, shellArgs, {
765
+ name: options.ptyTerm || 'xterm-256color',
766
+ cols: options.ptyCols || 120,
767
+ rows: options.ptyRows || 30,
768
+ cwd: process.cwd(),
769
+ env: options.env || process.env,
770
+ });
771
+
772
+ // Add to smartexit (wrap in a minimal object with pid)
773
+ this.smartexit.addProcess({ pid: ptyProcess.pid } as any);
774
+
775
+ // Handle output (stdout and stderr are combined in PTY)
776
+ ptyProcess.onData((data: string) => {
777
+ if (!options.silent) {
778
+ shellLogInstance.writeToConsole(data);
779
+ }
780
+ shellLogInstance.addToBuffer(data);
781
+ });
782
+
783
+ // Wrap PTY termination into a Promise
784
+ const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
785
+ ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
786
+ this.smartexit.removeProcess({ pid: ptyProcess.pid } as any);
787
+
788
+ const execResult: IExecResult = {
789
+ exitCode: exitCode ?? (signal ? 1 : 0),
790
+ stdout: shellLogInstance.logStore.toString(),
791
+ };
792
+
793
+ if (options.strict && exitCode !== 0) {
794
+ reject(new Error(`Command "${options.commandString}" exited with code ${exitCode}`));
795
+ } else {
796
+ resolve(execResult);
797
+ }
798
+ });
799
+ });
800
+
801
+ // Create input methods for PTY
802
+ const sendInput = async (input: string): Promise<void> => {
803
+ return new Promise((resolve, reject) => {
804
+ try {
805
+ ptyProcess.write(input);
806
+ resolve();
807
+ } catch (error) {
808
+ reject(error);
809
+ }
810
+ });
811
+ };
812
+
813
+ const sendLine = async (line: string): Promise<void> => {
814
+ // Use \r for PTY (carriage return is typical for terminal line discipline)
815
+ return sendInput(line + '\r');
816
+ };
817
+
818
+ const endInput = (): void => {
819
+ // Send EOF (Ctrl+D) to PTY
820
+ ptyProcess.write('\x04');
821
+ };
822
+
823
+ // If interactive control is enabled but not streaming, return interactive interface
824
+ if (options.interactiveControl && !options.streaming) {
825
+ return {
826
+ exitCode: 0, // Will be updated when process ends
827
+ stdout: '', // Will be updated when process ends
828
+ sendInput,
829
+ sendLine,
830
+ endInput,
831
+ finalPromise: childProcessEnded,
832
+ } as IExecResultInteractive;
833
+ }
834
+
835
+ // If streaming mode is enabled, return a streaming interface
836
+ if (options.streaming) {
837
+ return {
838
+ childProcess: { pid: ptyProcess.pid } as any, // Minimal compatibility object
839
+ finalPromise: childProcessEnded,
840
+ sendInput,
841
+ sendLine,
842
+ endInput,
843
+ kill: async () => {
844
+ if (options.debug) {
845
+ console.log(`[smartshell] Killing PTY process ${ptyProcess.pid}`);
846
+ }
847
+ ptyProcess.kill();
848
+ },
849
+ terminate: async () => {
850
+ if (options.debug) {
851
+ console.log(`[smartshell] Terminating PTY process ${ptyProcess.pid}`);
852
+ }
853
+ ptyProcess.kill('SIGTERM');
854
+ },
855
+ keyboardInterrupt: async () => {
856
+ if (options.debug) {
857
+ console.log(`[smartshell] Sending SIGINT to PTY process ${ptyProcess.pid}`);
858
+ }
859
+ ptyProcess.kill('SIGINT');
860
+ },
861
+ customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
862
+ if (options.debug) {
863
+ console.log(`[smartshell] Sending ${signal} to PTY process ${ptyProcess.pid}`);
864
+ }
865
+ ptyProcess.kill(signal as any);
866
+ },
867
+ } as IExecResultStreaming;
868
+ }
869
+
870
+ // For non-streaming mode, wait for the process to complete
871
+ return await childProcessEnded;
872
+ }
873
+ }