@push.rocks/smartshell 3.2.4 → 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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.smartshell.d.ts +70 -2
- package/dist_ts/classes.smartshell.js +595 -24
- package/package.json +1 -1
- package/readme.md +334 -516
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.smartshell.ts +695 -25
package/ts/classes.smartshell.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
459
|
+
exitCode,
|
|
112
460
|
stdout: shellLogInstance.logStore.toString(),
|
|
461
|
+
signal: signal || undefined,
|
|
462
|
+
stderr: stderrBuffer,
|
|
113
463
|
};
|
|
114
464
|
|
|
115
|
-
if (options.strict &&
|
|
116
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
+
}
|