feat(smartshell): Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests
This commit is contained in:
Binary file not shown.
12
changelog.md
12
changelog.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-08-17 - 3.3.0 - feat(smartshell)
|
||||||
|
Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests
|
||||||
|
|
||||||
|
- Introduce execSpawn family (execSpawn, execSpawnStreaming, execSpawnInteractiveControl) for shell-free, secure execution of untrusted input (shell:false).
|
||||||
|
- Add PTY support (optional node-pty) with execInteractiveControlPty and execStreamingInteractiveControlPty; PTY is lazy-loaded and documented as an optional dependency.
|
||||||
|
- Expose interactive control primitives (sendInput, sendLine, endInput, finalPromise) for both spawn and shell-based executions, and streaming interfaces with process control (terminate, kill, keyboardInterrupt, customSignal).
|
||||||
|
- Implement timeouts, maxBuffer limits and onData callbacks to prevent OOM, stream output incrementally, and support early termination and debugging logs.
|
||||||
|
- Improve process lifecycle handling: safe unpipe/unpipe-on-error, smartexit integration, and safer signal-based tree-kill behavior.
|
||||||
|
- Enhance execAndWaitForLine with timeout and terminateOnMatch options to allow pattern-based waits with configurable behavior.
|
||||||
|
- Update README with a Security Guide recommending execSpawn for untrusted input, PTY usage guidance, and new feature documentation (timeouts, buffer limits, debug mode, environment control).
|
||||||
|
- Add extensive tests covering error handling, interactive control, passthrough, PTY behavior, spawn behavior, silent/streaming modes and environment propagation.
|
||||||
|
|
||||||
## 2025-08-16 - 3.2.4 - fix(tests)
|
## 2025-08-16 - 3.2.4 - fix(tests)
|
||||||
Update tests & CI config, bump deps, add docs and project configs
|
Update tests & CI config, bump deps, add docs and project configs
|
||||||
|
|
||||||
|
222
test/test.errorHandling.ts
Normal file
222
test/test.errorHandling.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartshell from '../ts/index.js';
|
||||||
|
|
||||||
|
tap.test('should handle EPIPE errors gracefully', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const streaming = await testSmartshell.execStreamingInteractiveControl('head -n 1');
|
||||||
|
|
||||||
|
// Send more data after head exits (will cause EPIPE)
|
||||||
|
await streaming.sendLine('Line 1');
|
||||||
|
|
||||||
|
// This should not throw even though head has exited
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
await streaming.sendLine('Line 2');
|
||||||
|
await streaming.sendLine('Line 3');
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
// EPIPE or destroyed stdin is expected
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await streaming.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Line 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle strict mode with non-zero exit codes', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testSmartshell.execStrict('exit 42');
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toBeTrue();
|
||||||
|
expect(errorMessage).toContain('exited with code 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle strict mode with signal termination', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use execSpawn with strict mode and kill it
|
||||||
|
const result = testSmartshell.execSpawn('sleep', ['10'], {
|
||||||
|
strict: true,
|
||||||
|
timeout: 100 // Will cause SIGTERM
|
||||||
|
});
|
||||||
|
await result;
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toBeTrue();
|
||||||
|
expect(errorMessage).toContain('terminated by signal');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execAndWaitForLine with timeout should reject properly', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testSmartshell.execAndWaitForLine(
|
||||||
|
'sleep 5 && echo "Too late"',
|
||||||
|
/Too late/,
|
||||||
|
false,
|
||||||
|
{ timeout: 100 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toBeTrue();
|
||||||
|
expect(errorMessage).toContain('Timeout waiting for pattern');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execAndWaitForLine with terminateOnMatch should stop process', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await testSmartshell.execAndWaitForLine(
|
||||||
|
'echo "Match this" && sleep 5',
|
||||||
|
/Match this/,
|
||||||
|
false,
|
||||||
|
{ terminateOnMatch: true }
|
||||||
|
);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
// Should terminate immediately after match, not wait for sleep
|
||||||
|
expect(duration).toBeLessThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle process ending without matching pattern', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testSmartshell.execAndWaitForLine(
|
||||||
|
'echo "Wrong text"',
|
||||||
|
/Never appears/,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toBeTrue();
|
||||||
|
expect(errorMessage).toContain('Process ended without matching pattern');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('passthrough unpipe should handle destroyed stdin gracefully', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// This should complete without throwing even if stdin operations fail
|
||||||
|
const result = await testSmartshell.execPassthrough('echo "Test passthrough unpipe"');
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Test passthrough unpipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle write after stream destroyed', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const interactive = await testSmartshell.execInteractiveControl('true'); // Exits immediately
|
||||||
|
|
||||||
|
// Wait for process to exit
|
||||||
|
await interactive.finalPromise;
|
||||||
|
|
||||||
|
// Try to write after process has exited
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
await interactive.sendLine('This should fail');
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toContain('destroyed');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('debug mode should log additional information', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture console.log output
|
||||||
|
const originalLog = console.log;
|
||||||
|
let debugOutput = '';
|
||||||
|
console.log = (msg: string) => {
|
||||||
|
debugOutput += msg + '\n';
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streaming = await testSmartshell.execSpawnStreaming('echo', ['Debug test'], {
|
||||||
|
debug: true
|
||||||
|
});
|
||||||
|
await streaming.terminate();
|
||||||
|
await streaming.finalPromise;
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have logged debug messages
|
||||||
|
expect(debugOutput).toContain('[smartshell]');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('custom environment variables should be passed correctly', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await testSmartshell.execSpawn('bash', ['-c', 'echo $CUSTOM_VAR'], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CUSTOM_VAR: 'test_value_123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('test_value_123');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
84
test/test.interactiveControl.ts
Normal file
84
test/test.interactiveControl.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartshell from '../ts/index.js';
|
||||||
|
|
||||||
|
tap.test('should handle programmatic input control with simple commands', async (tools) => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use cat which works well with pipe mode
|
||||||
|
const interactive = await testSmartshell.execInteractiveControl('cat');
|
||||||
|
|
||||||
|
// Send input programmatically
|
||||||
|
await interactive.sendLine('TestUser');
|
||||||
|
interactive.endInput();
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('TestUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle streaming interactive control with cat', async (tools) => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use cat for reliable pipe mode operation
|
||||||
|
const streaming = await testSmartshell.execStreamingInteractiveControl('cat');
|
||||||
|
|
||||||
|
// Send multiple inputs
|
||||||
|
await streaming.sendLine('One');
|
||||||
|
await streaming.sendLine('Two');
|
||||||
|
streaming.endInput();
|
||||||
|
|
||||||
|
const result = await streaming.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('One');
|
||||||
|
expect(result.stdout).toContain('Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle sendInput without newline', async (tools) => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use cat for testing input without newline
|
||||||
|
const interactive = await testSmartshell.execInteractiveControl('cat');
|
||||||
|
|
||||||
|
// Send characters without newline, then newline, then EOF
|
||||||
|
await interactive.sendInput('ABC');
|
||||||
|
await interactive.sendInput('DEF');
|
||||||
|
await interactive.sendInput('\n');
|
||||||
|
interactive.endInput();
|
||||||
|
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('ABCDEF');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should mix passthrough and interactive control modes', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that passthrough still works
|
||||||
|
const passthroughResult = await testSmartshell.execPassthrough('echo "Passthrough works"');
|
||||||
|
expect(passthroughResult.exitCode).toEqual(0);
|
||||||
|
expect(passthroughResult.stdout).toContain('Passthrough works');
|
||||||
|
|
||||||
|
// Test that interactive control works
|
||||||
|
const interactiveResult = await testSmartshell.execInteractiveControl('echo "Interactive control works"');
|
||||||
|
const finalResult = await interactiveResult.finalPromise;
|
||||||
|
expect(finalResult.exitCode).toEqual(0);
|
||||||
|
expect(finalResult.stdout).toContain('Interactive control works');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Tests requiring bash read with prompts should use PTY mode
|
||||||
|
// See test.pty.ts for examples of testing commands that require terminal features
|
||||||
|
|
||||||
|
export default tap.start();
|
146
test/test.pty.ts
Normal file
146
test/test.pty.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartshell from '../ts/index.js';
|
||||||
|
|
||||||
|
// Helper to check if node-pty is available
|
||||||
|
const isPtyAvailable = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await import('node-pty');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('PTY: should handle bash read with prompts correctly', async (tools) => {
|
||||||
|
const ptyAvailable = await isPtyAvailable();
|
||||||
|
if (!ptyAvailable) {
|
||||||
|
console.log('Skipping PTY test - node-pty not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// This test should work with PTY (bash read with prompt)
|
||||||
|
const interactive = await testSmartshell.execInteractiveControlPty("bash -c 'read -p \"Enter name: \" name && echo \"Hello, $name\"'");
|
||||||
|
|
||||||
|
// Send input programmatically
|
||||||
|
await interactive.sendLine('TestUser');
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Enter name:'); // Prompt should be visible with PTY
|
||||||
|
expect(result.stdout).toContain('Hello, TestUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('PTY: should handle terminal colors and escape sequences', async (tools) => {
|
||||||
|
const ptyAvailable = await isPtyAvailable();
|
||||||
|
if (!ptyAvailable) {
|
||||||
|
console.log('Skipping PTY test - node-pty not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ls --color=auto should produce colors in PTY mode
|
||||||
|
const result = await testSmartshell.execInteractiveControlPty('ls --color=always /tmp');
|
||||||
|
const finalResult = await result.finalPromise;
|
||||||
|
|
||||||
|
expect(finalResult.exitCode).toEqual(0);
|
||||||
|
// Check for ANSI escape sequences (colors) in output
|
||||||
|
const hasColors = /\x1b\[[0-9;]*m/.test(finalResult.stdout);
|
||||||
|
expect(hasColors).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('PTY: should handle interactive password prompt simulation', async (tools) => {
|
||||||
|
const ptyAvailable = await isPtyAvailable();
|
||||||
|
if (!ptyAvailable) {
|
||||||
|
console.log('Skipping PTY test - node-pty not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate a password prompt scenario
|
||||||
|
const interactive = await testSmartshell.execStreamingInteractiveControlPty(
|
||||||
|
"bash -c 'read -s -p \"Password: \" pass && echo && echo \"Got password of length ${#pass}\"'"
|
||||||
|
);
|
||||||
|
|
||||||
|
await tools.delayFor(100);
|
||||||
|
await interactive.sendLine('secretpass');
|
||||||
|
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Password:');
|
||||||
|
expect(result.stdout).toContain('Got password of length 10');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('PTY: should handle terminal size options', async (tools) => {
|
||||||
|
const ptyAvailable = await isPtyAvailable();
|
||||||
|
if (!ptyAvailable) {
|
||||||
|
console.log('Skipping PTY test - node-pty not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check terminal size using stty
|
||||||
|
const result = await testSmartshell.execInteractiveControlPty('stty size');
|
||||||
|
const finalResult = await result.finalPromise;
|
||||||
|
|
||||||
|
expect(finalResult.exitCode).toEqual(0);
|
||||||
|
// Default size should be 30 rows x 120 cols as set in _execCommandPty
|
||||||
|
expect(finalResult.stdout).toContain('30 120');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('PTY: should handle Ctrl+C (SIGINT) properly', async (tools) => {
|
||||||
|
const ptyAvailable = await isPtyAvailable();
|
||||||
|
if (!ptyAvailable) {
|
||||||
|
console.log('Skipping PTY test - node-pty not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start a long-running process
|
||||||
|
const streaming = await testSmartshell.execStreamingInteractiveControlPty('sleep 10');
|
||||||
|
|
||||||
|
// Send interrupt after a short delay
|
||||||
|
await tools.delayFor(100);
|
||||||
|
await streaming.keyboardInterrupt();
|
||||||
|
|
||||||
|
const result = await streaming.finalPromise;
|
||||||
|
// Process should exit with non-zero code due to interrupt
|
||||||
|
expect(result.exitCode).not.toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Regular pipe mode should still work alongside PTY', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regular mode should work without PTY
|
||||||
|
const interactive = await testSmartshell.execInteractiveControl('echo "Pipe mode works"');
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Pipe mode works');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
54
test/test.simpleInteractive.ts
Normal file
54
test/test.simpleInteractive.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartshell from '../ts/index.js';
|
||||||
|
|
||||||
|
tap.test('should send input to cat command', async (tools) => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use cat which simply echoes what it receives
|
||||||
|
const interactive = await testSmartshell.execInteractiveControl('cat');
|
||||||
|
|
||||||
|
// Send some text and close stdin
|
||||||
|
await interactive.sendLine('Hello World');
|
||||||
|
interactive.endInput(); // Close stdin properly
|
||||||
|
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should work with simple echo', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// This should work without any input
|
||||||
|
const interactive = await testSmartshell.execInteractiveControl('echo "Direct test"');
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Direct test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle streaming with input control', async (tools) => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with streaming and cat
|
||||||
|
const streaming = await testSmartshell.execStreamingInteractiveControl('cat');
|
||||||
|
|
||||||
|
await streaming.sendLine('Line 1');
|
||||||
|
await streaming.sendLine('Line 2');
|
||||||
|
streaming.endInput(); // Close stdin
|
||||||
|
|
||||||
|
const result = await streaming.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Line 1');
|
||||||
|
expect(result.stdout).toContain('Line 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
150
test/test.spawn.ts
Normal file
150
test/test.spawn.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartshell from '../ts/index.js';
|
||||||
|
|
||||||
|
tap.test('execSpawn should execute commands with args array (shell:false)', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test basic command with args
|
||||||
|
const result = await testSmartshell.execSpawn('echo', ['Hello', 'World']);
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn should handle command not found errors', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
await testSmartshell.execSpawn('nonexistentcommand123', ['arg1']);
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.code).toEqual('ENOENT');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn should properly escape arguments', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that shell metacharacters are treated as literals
|
||||||
|
const result = await testSmartshell.execSpawn('echo', ['$HOME', '&&', 'ls']);
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
// Should output literal strings, not expanded/executed
|
||||||
|
expect(result.stdout).toContain('$HOME && ls');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn streaming should work', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const streaming = await testSmartshell.execSpawnStreaming('echo', ['Streaming test']);
|
||||||
|
const result = await streaming.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Streaming test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn interactive control should work', async (tools) => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const interactive = await testSmartshell.execSpawnInteractiveControl('cat', []);
|
||||||
|
|
||||||
|
await interactive.sendLine('Input line');
|
||||||
|
interactive.endInput();
|
||||||
|
|
||||||
|
const result = await interactive.finalPromise;
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('Input line');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn should capture stderr', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ls on non-existent directory should produce stderr
|
||||||
|
const result = await testSmartshell.execSpawn('ls', ['/nonexistent/directory/path']);
|
||||||
|
expect(result.exitCode).not.toEqual(0);
|
||||||
|
expect(result.stderr).toBeTruthy();
|
||||||
|
expect(result.stderr).toContain('No such file or directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn with timeout should terminate process', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await testSmartshell.execSpawn('sleep', ['10'], { timeout: 100 });
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
// Process should be terminated by timeout
|
||||||
|
expect(duration).toBeLessThan(500);
|
||||||
|
expect(result.exitCode).not.toEqual(0);
|
||||||
|
expect(result.signal).toBeTruthy(); // Should have been killed by signal
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn with maxBuffer should truncate output', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate large output
|
||||||
|
const result = await testSmartshell.execSpawn('bash', ['-c', 'for i in {1..1000}; do echo "Line $i with some padding text to make it longer"; done'], {
|
||||||
|
maxBuffer: 1024, // Very small buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout).toContain('[Output truncated - exceeded maxBuffer]');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn with onData callback should stream data', async (tools) => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let dataReceived = '';
|
||||||
|
const result = await testSmartshell.execSpawn('echo', ['Test data'], {
|
||||||
|
onData: (chunk) => {
|
||||||
|
dataReceived += chunk.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(dataReceived).toContain('Test data');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn with signal should report signal in result', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const streaming = await testSmartshell.execSpawnStreaming('sleep', ['10']);
|
||||||
|
|
||||||
|
// Send SIGTERM after a short delay
|
||||||
|
setTimeout(() => streaming.terminate(), 100);
|
||||||
|
|
||||||
|
const result = await streaming.finalPromise;
|
||||||
|
expect(result.exitCode).not.toEqual(0);
|
||||||
|
expect(result.signal).toEqual('SIGTERM');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartshell',
|
name: '@push.rocks/smartshell',
|
||||||
version: '3.2.4',
|
version: '3.3.0',
|
||||||
description: 'A library for executing shell commands using promises.'
|
description: 'A library for executing shell commands using promises.'
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,15 @@ import * as cp from 'child_process';
|
|||||||
export interface IExecResult {
|
export interface IExecResult {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
|
signal?: NodeJS.Signals;
|
||||||
|
stderr?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IExecResultInteractive extends IExecResult {
|
||||||
|
sendInput: (input: string) => Promise<void>;
|
||||||
|
sendLine: (line: string) => Promise<void>;
|
||||||
|
endInput: () => void;
|
||||||
|
finalPromise: Promise<IExecResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExecResultStreaming {
|
export interface IExecResultStreaming {
|
||||||
@@ -17,6 +26,9 @@ export interface IExecResultStreaming {
|
|||||||
terminate: () => Promise<void>;
|
terminate: () => Promise<void>;
|
||||||
keyboardInterrupt: () => Promise<void>;
|
keyboardInterrupt: () => Promise<void>;
|
||||||
customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise<void>;
|
customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise<void>;
|
||||||
|
sendInput: (input: string) => Promise<void>;
|
||||||
|
sendLine: (line: string) => Promise<void>;
|
||||||
|
endInput: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IExecOptions {
|
interface IExecOptions {
|
||||||
@@ -26,6 +38,23 @@ interface IExecOptions {
|
|||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
passthrough?: boolean;
|
passthrough?: boolean;
|
||||||
|
interactiveControl?: boolean;
|
||||||
|
usePty?: boolean;
|
||||||
|
ptyCols?: number;
|
||||||
|
ptyRows?: number;
|
||||||
|
ptyTerm?: string;
|
||||||
|
ptyShell?: string;
|
||||||
|
maxBuffer?: number;
|
||||||
|
onData?: (chunk: Buffer | string) => void;
|
||||||
|
timeout?: number;
|
||||||
|
debug?: boolean;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISpawnOptions extends Omit<IExecOptions, 'commandString'> {
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Smartshell {
|
export class Smartshell {
|
||||||
@@ -73,92 +102,441 @@ export class Smartshell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a command and returns either a non-streaming result or a streaming interface.
|
* Executes a command with args array (shell:false) for security
|
||||||
*/
|
*/
|
||||||
private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming> {
|
private async _execSpawn(options: ISpawnOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
|
||||||
const commandToExecute = this.shellEnv.createEnvExecString(options.commandString);
|
|
||||||
const shellLogInstance = new ShellLog();
|
const shellLogInstance = new ShellLog();
|
||||||
|
let stderrBuffer = '';
|
||||||
|
const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
|
||||||
|
let bufferExceeded = false;
|
||||||
|
|
||||||
|
// Handle PTY mode if requested
|
||||||
|
if (options.usePty) {
|
||||||
|
throw new Error('PTY mode is not yet supported with execSpawn. Use exec methods with shell:true for PTY.');
|
||||||
|
}
|
||||||
|
|
||||||
const execChildProcess = cp.spawn(commandToExecute, [], {
|
const execChildProcess = cp.spawn(options.command, options.args || [], {
|
||||||
shell: true,
|
shell: false, // SECURITY: Never use shell with untrusted input
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: process.env,
|
env: options.env || process.env,
|
||||||
detached: false,
|
detached: false,
|
||||||
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.smartexit.addProcess(execChildProcess);
|
this.smartexit.addProcess(execChildProcess);
|
||||||
|
|
||||||
// Connect stdin if passthrough is enabled
|
// Handle timeout
|
||||||
if (options.passthrough && execChildProcess.stdin) {
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
|
if (options.timeout) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
||||||
|
}
|
||||||
|
execChildProcess.kill('SIGTERM');
|
||||||
|
}, options.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect stdin if passthrough is enabled (but not for interactive control)
|
||||||
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
||||||
process.stdin.pipe(execChildProcess.stdin);
|
process.stdin.pipe(execChildProcess.stdin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture stdout and stderr output.
|
// Create input methods for interactive control
|
||||||
|
const sendInput = async (input: string): Promise<void> => {
|
||||||
|
if (!execChildProcess.stdin) {
|
||||||
|
throw new Error('stdin is not available for this process');
|
||||||
|
}
|
||||||
|
if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
|
||||||
|
throw new Error('stdin has been destroyed or is not writable');
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execChildProcess.stdin.write(input, 'utf8', (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendLine = async (line: string): Promise<void> => {
|
||||||
|
return sendInput(line + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const endInput = (): void => {
|
||||||
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
||||||
|
execChildProcess.stdin.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture stdout and stderr output
|
||||||
execChildProcess.stdout.on('data', (data) => {
|
execChildProcess.stdout.on('data', (data) => {
|
||||||
if (!options.silent) {
|
if (!options.silent) {
|
||||||
shellLogInstance.writeToConsole(data);
|
shellLogInstance.writeToConsole(data);
|
||||||
}
|
}
|
||||||
shellLogInstance.addToBuffer(data);
|
|
||||||
|
if (options.onData) {
|
||||||
|
options.onData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bufferExceeded) {
|
||||||
|
shellLogInstance.addToBuffer(data);
|
||||||
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
||||||
|
bufferExceeded = true;
|
||||||
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
execChildProcess.stderr.on('data', (data) => {
|
execChildProcess.stderr.on('data', (data) => {
|
||||||
if (!options.silent) {
|
if (!options.silent) {
|
||||||
shellLogInstance.writeToConsole(data);
|
shellLogInstance.writeToConsole(data);
|
||||||
}
|
}
|
||||||
shellLogInstance.addToBuffer(data);
|
|
||||||
|
const dataStr = data.toString();
|
||||||
|
stderrBuffer += dataStr;
|
||||||
|
|
||||||
|
if (options.onData) {
|
||||||
|
options.onData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bufferExceeded) {
|
||||||
|
shellLogInstance.addToBuffer(data);
|
||||||
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
||||||
|
bufferExceeded = true;
|
||||||
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap child process termination into a Promise.
|
// Wrap child process termination into a Promise
|
||||||
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
||||||
execChildProcess.on('exit', (code, signal) => {
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
this.smartexit.removeProcess(execChildProcess);
|
this.smartexit.removeProcess(execChildProcess);
|
||||||
|
|
||||||
// Unpipe stdin when process ends if passthrough was enabled
|
// Safely unpipe stdin when process ends if passthrough was enabled
|
||||||
if (options.passthrough) {
|
if (options.passthrough && !options.interactiveControl) {
|
||||||
process.stdin.unpipe(execChildProcess.stdin);
|
try {
|
||||||
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
||||||
|
process.stdin.unpipe(execChildProcess.stdin);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Error unpiping stdin: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
||||||
const execResult: IExecResult = {
|
const execResult: IExecResult = {
|
||||||
exitCode: typeof code === 'number' ? code : (signal ? 1 : 0),
|
exitCode,
|
||||||
stdout: shellLogInstance.logStore.toString(),
|
stdout: shellLogInstance.logStore.toString(),
|
||||||
|
signal: signal || undefined,
|
||||||
|
stderr: stderrBuffer,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.strict && code !== 0) {
|
if (options.strict && exitCode !== 0) {
|
||||||
reject(new Error(`Command "${options.commandString}" exited with code ${code}`));
|
const errorMsg = signal
|
||||||
|
? `Command "${options.command}" terminated by signal ${signal}`
|
||||||
|
: `Command "${options.command}" exited with code ${exitCode}`;
|
||||||
|
reject(new Error(errorMsg));
|
||||||
} else {
|
} else {
|
||||||
resolve(execResult);
|
resolve(execResult);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
execChildProcess.on('error', (error) => {
|
execChildProcess.once('exit', handleExit);
|
||||||
|
execChildProcess.once('error', (error) => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
this.smartexit.removeProcess(execChildProcess);
|
this.smartexit.removeProcess(execChildProcess);
|
||||||
// Unpipe stdin when process errors if passthrough was enabled
|
|
||||||
if (options.passthrough && execChildProcess.stdin) {
|
// Safely unpipe stdin when process errors if passthrough was enabled
|
||||||
process.stdin.unpipe(execChildProcess.stdin);
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
||||||
|
try {
|
||||||
|
if (!execChildProcess.stdin.destroyed) {
|
||||||
|
process.stdin.unpipe(execChildProcess.stdin);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// If streaming mode is enabled, return a streaming interface immediately.
|
// If interactive control is enabled but not streaming, return interactive interface
|
||||||
|
if (options.interactiveControl && !options.streaming) {
|
||||||
|
return {
|
||||||
|
exitCode: 0, // Will be updated when process ends
|
||||||
|
stdout: '', // Will be updated when process ends
|
||||||
|
sendInput,
|
||||||
|
sendLine,
|
||||||
|
endInput,
|
||||||
|
finalPromise: childProcessEnded,
|
||||||
|
} as IExecResultInteractive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If streaming mode is enabled, return a streaming interface
|
||||||
if (options.streaming) {
|
if (options.streaming) {
|
||||||
return {
|
return {
|
||||||
childProcess: execChildProcess,
|
childProcess: execChildProcess,
|
||||||
finalPromise: childProcessEnded,
|
finalPromise: childProcessEnded,
|
||||||
|
sendInput,
|
||||||
|
sendLine,
|
||||||
|
endInput,
|
||||||
kill: async () => {
|
kill: async () => {
|
||||||
console.log(`Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
|
||||||
},
|
},
|
||||||
terminate: async () => {
|
terminate: async () => {
|
||||||
console.log(`Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
|
||||||
},
|
},
|
||||||
keyboardInterrupt: async () => {
|
keyboardInterrupt: async () => {
|
||||||
console.log(`Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
|
||||||
},
|
},
|
||||||
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
||||||
console.log(`Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
|
||||||
|
},
|
||||||
|
} as IExecResultStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-streaming mode, wait for the process to complete
|
||||||
|
return await childProcessEnded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a command and returns either a non-streaming result or a streaming interface.
|
||||||
|
*/
|
||||||
|
private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
|
||||||
|
const commandToExecute = this.shellEnv.createEnvExecString(options.commandString);
|
||||||
|
const shellLogInstance = new ShellLog();
|
||||||
|
let stderrBuffer = '';
|
||||||
|
const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
|
||||||
|
let bufferExceeded = false;
|
||||||
|
|
||||||
|
// Handle PTY mode if requested
|
||||||
|
if (options.usePty) {
|
||||||
|
return await this._execCommandPty(options, commandToExecute, shellLogInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const execChildProcess = cp.spawn(commandToExecute, [], {
|
||||||
|
shell: true,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: options.env || process.env,
|
||||||
|
detached: false,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.smartexit.addProcess(execChildProcess);
|
||||||
|
|
||||||
|
// Handle timeout
|
||||||
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
|
if (options.timeout) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
||||||
|
}
|
||||||
|
execChildProcess.kill('SIGTERM');
|
||||||
|
}, options.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect stdin if passthrough is enabled (but not for interactive control)
|
||||||
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
||||||
|
process.stdin.pipe(execChildProcess.stdin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create input methods for interactive control
|
||||||
|
const sendInput = async (input: string): Promise<void> => {
|
||||||
|
if (!execChildProcess.stdin) {
|
||||||
|
throw new Error('stdin is not available for this process');
|
||||||
|
}
|
||||||
|
if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
|
||||||
|
throw new Error('stdin has been destroyed or is not writable');
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execChildProcess.stdin.write(input, 'utf8', (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendLine = async (line: string): Promise<void> => {
|
||||||
|
return sendInput(line + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const endInput = (): void => {
|
||||||
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
||||||
|
execChildProcess.stdin.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture stdout and stderr output
|
||||||
|
execChildProcess.stdout.on('data', (data) => {
|
||||||
|
if (!options.silent) {
|
||||||
|
shellLogInstance.writeToConsole(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.onData) {
|
||||||
|
options.onData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bufferExceeded) {
|
||||||
|
shellLogInstance.addToBuffer(data);
|
||||||
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
||||||
|
bufferExceeded = true;
|
||||||
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
execChildProcess.stderr.on('data', (data) => {
|
||||||
|
if (!options.silent) {
|
||||||
|
shellLogInstance.writeToConsole(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStr = data.toString();
|
||||||
|
stderrBuffer += dataStr;
|
||||||
|
|
||||||
|
if (options.onData) {
|
||||||
|
options.onData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bufferExceeded) {
|
||||||
|
shellLogInstance.addToBuffer(data);
|
||||||
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
||||||
|
bufferExceeded = true;
|
||||||
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap child process termination into a Promise
|
||||||
|
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
||||||
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
this.smartexit.removeProcess(execChildProcess);
|
||||||
|
|
||||||
|
// Safely unpipe stdin when process ends if passthrough was enabled
|
||||||
|
if (options.passthrough && !options.interactiveControl) {
|
||||||
|
try {
|
||||||
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
||||||
|
process.stdin.unpipe(execChildProcess.stdin);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Error unpiping stdin: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
||||||
|
const execResult: IExecResult = {
|
||||||
|
exitCode,
|
||||||
|
stdout: shellLogInstance.logStore.toString(),
|
||||||
|
signal: signal || undefined,
|
||||||
|
stderr: stderrBuffer,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.strict && exitCode !== 0) {
|
||||||
|
const errorMsg = signal
|
||||||
|
? `Command "${options.commandString}" terminated by signal ${signal}`
|
||||||
|
: `Command "${options.commandString}" exited with code ${exitCode}`;
|
||||||
|
reject(new Error(errorMsg));
|
||||||
|
} else {
|
||||||
|
resolve(execResult);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
execChildProcess.once('exit', handleExit);
|
||||||
|
execChildProcess.once('error', (error) => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
this.smartexit.removeProcess(execChildProcess);
|
||||||
|
|
||||||
|
// Safely unpipe stdin when process errors if passthrough was enabled
|
||||||
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
||||||
|
try {
|
||||||
|
if (!execChildProcess.stdin.destroyed) {
|
||||||
|
process.stdin.unpipe(execChildProcess.stdin);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If interactive control is enabled but not streaming, return interactive interface
|
||||||
|
if (options.interactiveControl && !options.streaming) {
|
||||||
|
return {
|
||||||
|
exitCode: 0, // Will be updated when process ends
|
||||||
|
stdout: '', // Will be updated when process ends
|
||||||
|
sendInput,
|
||||||
|
sendLine,
|
||||||
|
endInput,
|
||||||
|
finalPromise: childProcessEnded,
|
||||||
|
} as IExecResultInteractive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If streaming mode is enabled, return a streaming interface
|
||||||
|
if (options.streaming) {
|
||||||
|
return {
|
||||||
|
childProcess: execChildProcess,
|
||||||
|
finalPromise: childProcessEnded,
|
||||||
|
sendInput,
|
||||||
|
sendLine,
|
||||||
|
endInput,
|
||||||
|
kill: async () => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
|
||||||
|
},
|
||||||
|
terminate: async () => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
|
||||||
|
},
|
||||||
|
keyboardInterrupt: async () => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
|
||||||
|
},
|
||||||
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
||||||
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
|
||||||
},
|
},
|
||||||
} as IExecResultStreaming;
|
} as IExecResultStreaming;
|
||||||
@@ -169,7 +547,9 @@ export class Smartshell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async exec(commandString: string): Promise<IExecResult> {
|
public async exec(commandString: string): Promise<IExecResult> {
|
||||||
return (await this._exec({ commandString })) as IExecResult;
|
const result = await this._exec({ commandString });
|
||||||
|
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
|
||||||
|
return result as IExecResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execSilent(commandString: string): Promise<IExecResult> {
|
public async execSilent(commandString: string): Promise<IExecResult> {
|
||||||
@@ -204,23 +584,290 @@ export class Smartshell {
|
|||||||
return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming;
|
return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async execInteractiveControl(commandString: string): Promise<IExecResultInteractive> {
|
||||||
|
return await this._exec({ commandString, interactiveControl: true }) as IExecResultInteractive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execStreamingInteractiveControl(commandString: string): Promise<IExecResultStreaming> {
|
||||||
|
return await this._exec({ commandString, streaming: true, interactiveControl: true }) as IExecResultStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execInteractiveControlPty(commandString: string): Promise<IExecResultInteractive> {
|
||||||
|
return await this._exec({ commandString, interactiveControl: true, usePty: true }) as IExecResultInteractive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execStreamingInteractiveControlPty(commandString: string): Promise<IExecResultStreaming> {
|
||||||
|
return await this._exec({ commandString, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a command with args array (shell:false) for security
|
||||||
|
* This is the recommended API for untrusted input
|
||||||
|
*/
|
||||||
|
public async execSpawn(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args'> = {}): Promise<IExecResult> {
|
||||||
|
const result = await this._execSpawn({ command, args, ...options });
|
||||||
|
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
|
||||||
|
return result as IExecResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a command with args array in streaming mode
|
||||||
|
*/
|
||||||
|
public async execSpawnStreaming(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'streaming'> = {}): Promise<IExecResultStreaming> {
|
||||||
|
return await this._execSpawn({ command, args, streaming: true, ...options }) as IExecResultStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a command with args array with interactive control
|
||||||
|
*/
|
||||||
|
public async execSpawnInteractiveControl(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'interactiveControl'> = {}): Promise<IExecResultInteractive> {
|
||||||
|
return await this._execSpawn({ command, args, interactiveControl: true, ...options }) as IExecResultInteractive;
|
||||||
|
}
|
||||||
|
|
||||||
public async execAndWaitForLine(
|
public async execAndWaitForLine(
|
||||||
commandString: string,
|
commandString: string,
|
||||||
regex: RegExp,
|
regex: RegExp,
|
||||||
silent: boolean = false
|
silent: boolean = false,
|
||||||
|
options: { timeout?: number; terminateOnMatch?: boolean } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const execStreamingResult = await this.execStreaming(commandString, silent);
|
const execStreamingResult = await this.execStreaming(commandString, silent);
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
execStreamingResult.childProcess.stdout.on('data', (chunk: Buffer | string) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let matched = false;
|
||||||
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Set up timeout if specified
|
||||||
|
if (options.timeout) {
|
||||||
|
timeoutHandle = setTimeout(async () => {
|
||||||
|
if (!matched) {
|
||||||
|
matched = true;
|
||||||
|
// Remove listener to prevent memory leak
|
||||||
|
execStreamingResult.childProcess.stdout.removeAllListeners('data');
|
||||||
|
await execStreamingResult.terminate();
|
||||||
|
reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`));
|
||||||
|
}
|
||||||
|
}, options.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataHandler = async (chunk: Buffer | string) => {
|
||||||
const data = typeof chunk === 'string' ? chunk : chunk.toString();
|
const data = typeof chunk === 'string' ? chunk : chunk.toString();
|
||||||
if (regex.test(data)) {
|
if (!matched && regex.test(data)) {
|
||||||
|
matched = true;
|
||||||
|
|
||||||
|
// Clear timeout if set
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove listener to prevent memory leak
|
||||||
|
execStreamingResult.childProcess.stdout.removeListener('data', dataHandler);
|
||||||
|
|
||||||
|
// Terminate process if requested
|
||||||
|
if (options.terminateOnMatch) {
|
||||||
|
await execStreamingResult.terminate();
|
||||||
|
await execStreamingResult.finalPromise;
|
||||||
|
}
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
execStreamingResult.childProcess.stdout.on('data', dataHandler);
|
||||||
|
|
||||||
|
// Also resolve/reject when process ends
|
||||||
|
execStreamingResult.finalPromise.then(() => {
|
||||||
|
if (!matched) {
|
||||||
|
matched = true;
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
reject(new Error('Process ended without matching pattern'));
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
if (!matched) {
|
||||||
|
matched = true;
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execAndWaitForLineSilent(commandString: string, regex: RegExp): Promise<void> {
|
public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise<void> {
|
||||||
return this.execAndWaitForLine(commandString, regex, true);
|
return this.execAndWaitForLine(commandString, regex, true, options);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private nodePty: any = null;
|
||||||
|
|
||||||
|
private async lazyLoadNodePty(): Promise<any> {
|
||||||
|
if (this.nodePty) {
|
||||||
|
return this.nodePty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to load node-pty if available
|
||||||
|
// @ts-ignore - node-pty is optional
|
||||||
|
this.nodePty = await import('node-pty');
|
||||||
|
return this.nodePty;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
'node-pty is required for PTY support but is not installed.\n' +
|
||||||
|
'Please install it as an optional dependency:\n' +
|
||||||
|
' pnpm add --save-optional node-pty\n' +
|
||||||
|
'Note: node-pty requires compilation and may have platform-specific requirements.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _execCommandPty(
|
||||||
|
options: IExecOptions,
|
||||||
|
commandToExecute: string,
|
||||||
|
shellLogInstance: ShellLog
|
||||||
|
): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
|
||||||
|
const pty = await this.lazyLoadNodePty();
|
||||||
|
|
||||||
|
// Platform-aware shell selection
|
||||||
|
let shell: string;
|
||||||
|
let shellArgs: string[];
|
||||||
|
|
||||||
|
if (options.ptyShell) {
|
||||||
|
// User-provided shell override
|
||||||
|
shell = options.ptyShell;
|
||||||
|
shellArgs = ['-c', commandToExecute];
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
// Windows: Use PowerShell by default, or cmd as fallback
|
||||||
|
const powershell = process.env.PROGRAMFILES
|
||||||
|
? `${process.env.PROGRAMFILES}\\PowerShell\\7\\pwsh.exe`
|
||||||
|
: 'powershell.exe';
|
||||||
|
|
||||||
|
// Check if PowerShell Core exists, otherwise use Windows PowerShell
|
||||||
|
const fs = await import('fs');
|
||||||
|
if (fs.existsSync(powershell)) {
|
||||||
|
shell = powershell;
|
||||||
|
shellArgs = ['-NoProfile', '-NonInteractive', '-Command', commandToExecute];
|
||||||
|
} else if (process.env.COMSPEC) {
|
||||||
|
shell = process.env.COMSPEC;
|
||||||
|
shellArgs = ['/d', '/s', '/c', commandToExecute];
|
||||||
|
} else {
|
||||||
|
shell = 'cmd.exe';
|
||||||
|
shellArgs = ['/d', '/s', '/c', commandToExecute];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// POSIX: Use SHELL env var or bash as default
|
||||||
|
shell = process.env.SHELL || '/bin/bash';
|
||||||
|
shellArgs = ['-c', commandToExecute];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PTY process
|
||||||
|
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||||
|
name: options.ptyTerm || 'xterm-256color',
|
||||||
|
cols: options.ptyCols || 120,
|
||||||
|
rows: options.ptyRows || 30,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: options.env || process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to smartexit (wrap in a minimal object with pid)
|
||||||
|
this.smartexit.addProcess({ pid: ptyProcess.pid } as any);
|
||||||
|
|
||||||
|
// Handle output (stdout and stderr are combined in PTY)
|
||||||
|
ptyProcess.onData((data: string) => {
|
||||||
|
if (!options.silent) {
|
||||||
|
shellLogInstance.writeToConsole(data);
|
||||||
|
}
|
||||||
|
shellLogInstance.addToBuffer(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap PTY termination into a Promise
|
||||||
|
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
||||||
|
ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||||
|
this.smartexit.removeProcess({ pid: ptyProcess.pid } as any);
|
||||||
|
|
||||||
|
const execResult: IExecResult = {
|
||||||
|
exitCode: exitCode ?? (signal ? 1 : 0),
|
||||||
|
stdout: shellLogInstance.logStore.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.strict && exitCode !== 0) {
|
||||||
|
reject(new Error(`Command "${options.commandString}" exited with code ${exitCode}`));
|
||||||
|
} else {
|
||||||
|
resolve(execResult);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create input methods for PTY
|
||||||
|
const sendInput = async (input: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
ptyProcess.write(input);
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendLine = async (line: string): Promise<void> => {
|
||||||
|
// Use \r for PTY (carriage return is typical for terminal line discipline)
|
||||||
|
return sendInput(line + '\r');
|
||||||
|
};
|
||||||
|
|
||||||
|
const endInput = (): void => {
|
||||||
|
// Send EOF (Ctrl+D) to PTY
|
||||||
|
ptyProcess.write('\x04');
|
||||||
|
};
|
||||||
|
|
||||||
|
// If interactive control is enabled but not streaming, return interactive interface
|
||||||
|
if (options.interactiveControl && !options.streaming) {
|
||||||
|
return {
|
||||||
|
exitCode: 0, // Will be updated when process ends
|
||||||
|
stdout: '', // Will be updated when process ends
|
||||||
|
sendInput,
|
||||||
|
sendLine,
|
||||||
|
endInput,
|
||||||
|
finalPromise: childProcessEnded,
|
||||||
|
} as IExecResultInteractive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If streaming mode is enabled, return a streaming interface
|
||||||
|
if (options.streaming) {
|
||||||
|
return {
|
||||||
|
childProcess: { pid: ptyProcess.pid } as any, // Minimal compatibility object
|
||||||
|
finalPromise: childProcessEnded,
|
||||||
|
sendInput,
|
||||||
|
sendLine,
|
||||||
|
endInput,
|
||||||
|
kill: async () => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Killing PTY process ${ptyProcess.pid}`);
|
||||||
|
}
|
||||||
|
ptyProcess.kill();
|
||||||
|
},
|
||||||
|
terminate: async () => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Terminating PTY process ${ptyProcess.pid}`);
|
||||||
|
}
|
||||||
|
ptyProcess.kill('SIGTERM');
|
||||||
|
},
|
||||||
|
keyboardInterrupt: async () => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Sending SIGINT to PTY process ${ptyProcess.pid}`);
|
||||||
|
}
|
||||||
|
ptyProcess.kill('SIGINT');
|
||||||
|
},
|
||||||
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Sending ${signal} to PTY process ${ptyProcess.pid}`);
|
||||||
|
}
|
||||||
|
ptyProcess.kill(signal as any);
|
||||||
|
},
|
||||||
|
} as IExecResultStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-streaming mode, wait for the process to complete
|
||||||
|
return await childProcessEnded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user