feat(smartshell): add cwd-aware execution options, structured strict-mode errors, and safer process tree termination
This commit is contained in:
@@ -32,8 +32,8 @@
|
|||||||
"@git.zone/cli": {
|
"@git.zone/cli": {
|
||||||
"release": {
|
"release": {
|
||||||
"registries": [
|
"registries": [
|
||||||
"https://verdaccio.lossleess.digital",
|
"https://registry.npmjs.org",
|
||||||
"https://registry.npmjs.org"
|
"https://verdaccio.lossless.digital"
|
||||||
],
|
],
|
||||||
"accessLevel": "public"
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-09 - 3.4.0 - feat(smartshell)
|
||||||
|
add cwd-aware execution options, structured strict-mode errors, and safer process tree termination
|
||||||
|
|
||||||
|
- adds cwd and env runtime options across exec, spawn, interactive, wait-for-line, and PTY execution APIs
|
||||||
|
- introduces SmartshellError with exit code, signal, stdout, stderr, and combined result details for strict-mode failures
|
||||||
|
- terminates timed-out commands via process tree killing to prevent detached child processes from continuing after timeout
|
||||||
|
|
||||||
## 2026-03-15 - 3.3.8 - fix(repo)
|
## 2026-03-15 - 3.3.8 - fix(repo)
|
||||||
remove obsolete Serena project configuration files
|
remove obsolete Serena project configuration files
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+8
-8
@@ -33,17 +33,17 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartshell",
|
"homepage": "https://code.foss.global/push.rocks/smartshell",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.1.2",
|
"@git.zone/tsbuild": "^4.4.1",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.4",
|
||||||
"@git.zone/tstest": "^3.2.0",
|
"@git.zone/tstest": "^3.6.6",
|
||||||
"@types/node": "^25.3.3"
|
"@types/node": "^25.6.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.1.0",
|
||||||
"@push.rocks/smartexit": "^2.0.3",
|
"@push.rocks/smartexit": "^2.0.3",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.4",
|
||||||
"@types/which": "^3.0.4",
|
"@types/which": "^3.0.4",
|
||||||
"which": "^6.0.1"
|
"which": "^7.0.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
],
|
],
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|||||||
Generated
+2987
-4199
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartshell from '../ts/index.js';
|
import * as smartshell from '../ts/index.js';
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
tap.test('should handle EPIPE errors gracefully', async () => {
|
tap.test('should handle EPIPE errors gracefully', async () => {
|
||||||
const testSmartshell = new smartshell.Smartshell({
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
@@ -40,7 +42,7 @@ tap.test('should handle strict mode with non-zero exit codes', async () => {
|
|||||||
await testSmartshell.execStrict('exit 42');
|
await testSmartshell.execStrict('exit 42');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorThrown = true;
|
errorThrown = true;
|
||||||
errorMessage = error.message;
|
errorMessage = getErrorMessage(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(errorThrown).toBeTrue();
|
expect(errorThrown).toBeTrue();
|
||||||
@@ -65,13 +67,39 @@ tap.test('should handle strict mode with signal termination', async () => {
|
|||||||
await result;
|
await result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorThrown = true;
|
errorThrown = true;
|
||||||
errorMessage = error.message;
|
errorMessage = getErrorMessage(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(errorThrown).toBeTrue();
|
expect(errorThrown).toBeTrue();
|
||||||
expect(errorMessage).toContain('terminated by signal');
|
expect(errorMessage).toContain('terminated by signal');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('strict mode errors should expose command result details', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let caughtError: smartshell.SmartshellError | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testSmartshell.execSpawn('bash', ['-c', 'echo stdout-value; echo stderr-value >&2; exit 7'], {
|
||||||
|
strict: true,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error as smartshell.SmartshellError;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caughtError).toBeInstanceOf(smartshell.SmartshellError);
|
||||||
|
expect(caughtError!.command).toEqual('bash');
|
||||||
|
expect(caughtError!.exitCode).toEqual(7);
|
||||||
|
expect(caughtError!.stdout).toContain('stdout-value');
|
||||||
|
expect(caughtError!.combinedOutput).toContain('stderr-value');
|
||||||
|
expect(caughtError!.stderr).toContain('stderr-value');
|
||||||
|
expect(caughtError!.result.exitCode).toEqual(7);
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('execAndWaitForLine with timeout should reject properly', async () => {
|
tap.test('execAndWaitForLine with timeout should reject properly', async () => {
|
||||||
const testSmartshell = new smartshell.Smartshell({
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
@@ -90,7 +118,7 @@ tap.test('execAndWaitForLine with timeout should reject properly', async () => {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorThrown = true;
|
errorThrown = true;
|
||||||
errorMessage = error.message;
|
errorMessage = getErrorMessage(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(errorThrown).toBeTrue();
|
expect(errorThrown).toBeTrue();
|
||||||
@@ -133,7 +161,7 @@ tap.test('should handle process ending without matching pattern', async () => {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorThrown = true;
|
errorThrown = true;
|
||||||
errorMessage = error.message;
|
errorMessage = getErrorMessage(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(errorThrown).toBeTrue();
|
expect(errorThrown).toBeTrue();
|
||||||
@@ -169,7 +197,7 @@ tap.test('should handle write after stream destroyed', async () => {
|
|||||||
await interactive.sendLine('This should fail');
|
await interactive.sendLine('This should fail');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorThrown = true;
|
errorThrown = true;
|
||||||
expect(error.message).toContain('destroyed');
|
expect(getErrorMessage(error)).toContain('destroyed');
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(errorThrown).toBeTrue();
|
expect(errorThrown).toBeTrue();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as smartshell from '../ts/index.js';
|
|||||||
// Helper to check if node-pty is available
|
// Helper to check if node-pty is available
|
||||||
const isPtyAvailable = async (): Promise<boolean> => {
|
const isPtyAvailable = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
|
// @ts-ignore - node-pty is an optional runtime dependency.
|
||||||
await import('node-pty');
|
await import('node-pty');
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ tap.test('execStreamingSilent should capture streaming output without console di
|
|||||||
const streamingResult = await testSmartshell.execStreamingSilent('echo "Line 1" && sleep 0.1 && echo "Line 2"');
|
const streamingResult = await testSmartshell.execStreamingSilent('echo "Line 1" && sleep 0.1 && echo "Line 2"');
|
||||||
|
|
||||||
let capturedData = '';
|
let capturedData = '';
|
||||||
streamingResult.childProcess.stdout.on('data', (data) => {
|
streamingResult.childProcess.stdout!.on('data', (data) => {
|
||||||
capturedData += data.toString();
|
capturedData += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+81
-1
@@ -1,5 +1,8 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartshell from '../ts/index.js';
|
import * as smartshell from '../ts/index.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
tap.test('execSpawn should execute commands with args array (shell:false)', async () => {
|
tap.test('execSpawn should execute commands with args array (shell:false)', async () => {
|
||||||
const testSmartshell = new smartshell.Smartshell({
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
@@ -13,6 +16,41 @@ tap.test('execSpawn should execute commands with args array (shell:false)', asyn
|
|||||||
expect(result.stdout).toContain('Hello World');
|
expect(result.stdout).toContain('Hello World');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn should run in the configured cwd', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartshell-cwd-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testSmartshell.execSpawn('node', ['-e', 'console.log(process.cwd())'], {
|
||||||
|
cwd: tmpDir,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout.trim()).toEqual(tmpDir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('execSilent should run shell commands in the configured cwd', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartshell-shell-cwd-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testSmartshell.execSilent('pwd', { cwd: tmpDir });
|
||||||
|
expect(result.exitCode).toEqual(0);
|
||||||
|
expect(result.stdout.trim()).toEqual(tmpDir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('execSpawn should handle command not found errors', async () => {
|
tap.test('execSpawn should handle command not found errors', async () => {
|
||||||
const testSmartshell = new smartshell.Smartshell({
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
@@ -24,7 +62,7 @@ tap.test('execSpawn should handle command not found errors', async () => {
|
|||||||
await testSmartshell.execSpawn('nonexistentcommand123', ['arg1']);
|
await testSmartshell.execSpawn('nonexistentcommand123', ['arg1']);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorThrown = true;
|
errorThrown = true;
|
||||||
expect(error.code).toEqual('ENOENT');
|
expect((error as NodeJS.ErrnoException).code).toEqual('ENOENT');
|
||||||
}
|
}
|
||||||
expect(errorThrown).toBeTrue();
|
expect(errorThrown).toBeTrue();
|
||||||
});
|
});
|
||||||
@@ -99,6 +137,48 @@ tap.test('execSpawn with timeout should terminate process', async () => {
|
|||||||
expect(result.signal).toBeTruthy(); // Should have been killed by signal
|
expect(result.signal).toBeTruthy(); // Should have been killed by signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('execSpawn timeout should terminate the spawned process tree', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
const markerPath = path.join(os.tmpdir(), `smartshell-spawn-timeout-${process.pid}-${Date.now()}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testSmartshell.execSpawn(
|
||||||
|
'bash',
|
||||||
|
['-c', `(sleep 0.6; touch "${markerPath}") & wait`],
|
||||||
|
{ timeout: 100, silent: true },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
expect(result.exitCode).not.toEqual(0);
|
||||||
|
expect(fs.existsSync(markerPath)).toBeFalse();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(markerPath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exec timeout should terminate the shell process tree', async () => {
|
||||||
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
|
executor: 'bash',
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
const markerPath = path.join(os.tmpdir(), `smartshell-shell-timeout-${process.pid}-${Date.now()}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testSmartshell.execSilent(`(sleep 0.6; touch "${markerPath}") & wait`, {
|
||||||
|
timeout: 100,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
expect(result.exitCode).not.toEqual(0);
|
||||||
|
expect(fs.existsSync(markerPath)).toBeFalse();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(markerPath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('execSpawn with maxBuffer should truncate output', async () => {
|
tap.test('execSpawn with maxBuffer should truncate output', async () => {
|
||||||
const testSmartshell = new smartshell.Smartshell({
|
const testSmartshell = new smartshell.Smartshell({
|
||||||
executor: 'bash',
|
executor: 'bash',
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ tap.test('smartshell should run async and silent', async () => {
|
|||||||
tap.test('smartshell should stream a shell execution', async () => {
|
tap.test('smartshell should stream a shell execution', async () => {
|
||||||
let done = smartpromise.defer();
|
let done = smartpromise.defer();
|
||||||
let execStreamingResponse = await testSmartshell.execStreaming('npm -v');
|
let execStreamingResponse = await testSmartshell.execStreaming('npm -v');
|
||||||
execStreamingResponse.childProcess.stdout.on('data', (data) => {
|
execStreamingResponse.childProcess.stdout!.on('data', (data) => {
|
||||||
done.resolve(data);
|
done.resolve(data);
|
||||||
});
|
});
|
||||||
let data = await done.promise;
|
let data = await done.promise;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartshell',
|
name: '@push.rocks/smartshell',
|
||||||
version: '3.3.8',
|
version: '3.4.0',
|
||||||
description: 'A library for executing shell commands using promises.'
|
description: 'A library for executing shell commands using promises.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class ShellEnv {
|
|||||||
private _setPath(commandStringArg: string): string {
|
private _setPath(commandStringArg: string): string {
|
||||||
let commandResult = commandStringArg;
|
let commandResult = commandStringArg;
|
||||||
let commandPaths: string[] = [];
|
let commandPaths: string[] = [];
|
||||||
commandPaths = commandPaths.concat(process.env.PATH.split(':'));
|
commandPaths = commandPaths.concat(process.env.PATH?.split(':') ?? []);
|
||||||
if (process.env.SMARTSHELL_PATH) {
|
if (process.env.SMARTSHELL_PATH) {
|
||||||
commandPaths = commandPaths.concat(process.env.SMARTSHELL_PATH.split(':'));
|
commandPaths = commandPaths.concat(process.env.SMARTSHELL_PATH.split(':'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export interface IDeferred<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SmartExecution {
|
export class SmartExecution {
|
||||||
public smartshell: Smartshell;
|
public smartshell!: Smartshell;
|
||||||
public currentStreamingExecution: IExecResultStreaming;
|
public currentStreamingExecution!: IExecResultStreaming;
|
||||||
public commandString: string;
|
public commandString: string;
|
||||||
|
|
||||||
private isRestartInProgress = false;
|
private isRestartInProgress = false;
|
||||||
|
|||||||
+168
-72
@@ -8,10 +8,37 @@ import * as cp from 'child_process';
|
|||||||
export interface IExecResult {
|
export interface IExecResult {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
|
/** stdout currently preserves smartshell's legacy combined stdout/stderr buffer. */
|
||||||
|
combinedOutput?: string;
|
||||||
signal?: NodeJS.Signals;
|
signal?: NodeJS.Signals;
|
||||||
stderr?: string;
|
stderr?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SmartshellError extends Error {
|
||||||
|
public command: string;
|
||||||
|
public result: IExecResult;
|
||||||
|
public exitCode: number;
|
||||||
|
public stdout: string;
|
||||||
|
public combinedOutput?: string;
|
||||||
|
public stderr?: string;
|
||||||
|
public signal?: NodeJS.Signals;
|
||||||
|
|
||||||
|
constructor(command: string, result: IExecResult) {
|
||||||
|
const reason = result.signal
|
||||||
|
? `terminated by signal ${result.signal}`
|
||||||
|
: `exited with code ${result.exitCode}`;
|
||||||
|
super(`Command "${command}" ${reason}`);
|
||||||
|
this.name = 'SmartshellError';
|
||||||
|
this.command = command;
|
||||||
|
this.result = result;
|
||||||
|
this.exitCode = result.exitCode;
|
||||||
|
this.stdout = result.stdout;
|
||||||
|
this.combinedOutput = result.combinedOutput;
|
||||||
|
this.stderr = result.stderr;
|
||||||
|
this.signal = result.signal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface IExecResultInteractive extends IExecResult {
|
export interface IExecResultInteractive extends IExecResult {
|
||||||
sendInput: (input: string) => Promise<void>;
|
sendInput: (input: string) => Promise<void>;
|
||||||
sendLine: (line: string) => Promise<void>;
|
sendLine: (line: string) => Promise<void>;
|
||||||
@@ -31,15 +58,7 @@ export interface IExecResultStreaming {
|
|||||||
endInput: () => void;
|
endInput: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IExecOptions {
|
export interface IExecRuntimeOptions {
|
||||||
commandString: string;
|
|
||||||
silent?: boolean;
|
|
||||||
strict?: boolean;
|
|
||||||
streaming?: boolean;
|
|
||||||
interactive?: boolean;
|
|
||||||
passthrough?: boolean;
|
|
||||||
interactiveControl?: boolean;
|
|
||||||
usePty?: boolean;
|
|
||||||
ptyCols?: number;
|
ptyCols?: number;
|
||||||
ptyRows?: number;
|
ptyRows?: number;
|
||||||
ptyTerm?: string;
|
ptyTerm?: string;
|
||||||
@@ -49,14 +68,35 @@ interface IExecOptions {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
|
cwd?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISpawnOptions extends Omit<IExecOptions, 'commandString'> {
|
interface IExecOptions extends IExecRuntimeOptions {
|
||||||
|
commandString: string;
|
||||||
|
silent?: boolean;
|
||||||
|
strict?: boolean;
|
||||||
|
streaming?: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
|
passthrough?: boolean;
|
||||||
|
interactiveControl?: boolean;
|
||||||
|
usePty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISpawnOptions extends IExecRuntimeOptions {
|
||||||
command: string;
|
command: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
|
silent?: boolean;
|
||||||
|
strict?: boolean;
|
||||||
|
streaming?: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
|
passthrough?: boolean;
|
||||||
|
interactiveControl?: boolean;
|
||||||
|
usePty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TExecCommandOptions = IExecRuntimeOptions;
|
||||||
|
|
||||||
export class Smartshell {
|
export class Smartshell {
|
||||||
public shellEnv: ShellEnv;
|
public shellEnv: ShellEnv;
|
||||||
public smartexit = new plugins.smartexit.SmartExit();
|
public smartexit = new plugins.smartexit.SmartExit();
|
||||||
@@ -68,9 +108,13 @@ export class Smartshell {
|
|||||||
/**
|
/**
|
||||||
* Executes a given command asynchronously.
|
* Executes a given command asynchronously.
|
||||||
*/
|
*/
|
||||||
private async _exec(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | void> {
|
private async _exec(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive | void> {
|
||||||
if (options.interactive) {
|
if (options.interactive) {
|
||||||
return await this._execInteractive({ commandString: options.commandString });
|
return await this._execInteractive({
|
||||||
|
commandString: options.commandString,
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return await this._execCommand(options);
|
return await this._execCommand(options);
|
||||||
}
|
}
|
||||||
@@ -78,7 +122,7 @@ export class Smartshell {
|
|||||||
/**
|
/**
|
||||||
* Executes an interactive command.
|
* Executes an interactive command.
|
||||||
*/
|
*/
|
||||||
private async _execInteractive(options: Pick<IExecOptions, 'commandString'>): Promise<void> {
|
private async _execInteractive(options: Pick<IExecOptions, 'commandString' | 'cwd' | 'env'>): Promise<void> {
|
||||||
// Skip interactive execution in CI environments.
|
// Skip interactive execution in CI environments.
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
return;
|
return;
|
||||||
@@ -89,6 +133,8 @@ export class Smartshell {
|
|||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
shell: true,
|
||||||
detached: true,
|
detached: true,
|
||||||
|
cwd: options.cwd || process.cwd(),
|
||||||
|
env: options.env || process.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.smartexit.addProcess(shell);
|
this.smartexit.addProcess(shell);
|
||||||
@@ -117,7 +163,7 @@ export class Smartshell {
|
|||||||
|
|
||||||
const execChildProcess = cp.spawn(options.command, options.args || [], {
|
const execChildProcess = cp.spawn(options.command, options.args || [], {
|
||||||
shell: false, // SECURITY: Never use shell with untrusted input
|
shell: false, // SECURITY: Never use shell with untrusted input
|
||||||
cwd: process.cwd(),
|
cwd: options.cwd || process.cwd(),
|
||||||
env: options.env || process.env,
|
env: options.env || process.env,
|
||||||
detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
|
detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
@@ -132,7 +178,7 @@ export class Smartshell {
|
|||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
||||||
}
|
}
|
||||||
execChildProcess.kill('SIGTERM');
|
void this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
|
||||||
}, options.timeout);
|
}, options.timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,18 +278,17 @@ export class Smartshell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
||||||
|
const combinedOutput = shellLogInstance.logStore.toString();
|
||||||
const execResult: IExecResult = {
|
const execResult: IExecResult = {
|
||||||
exitCode,
|
exitCode,
|
||||||
stdout: shellLogInstance.logStore.toString(),
|
stdout: combinedOutput,
|
||||||
|
combinedOutput,
|
||||||
signal: signal || undefined,
|
signal: signal || undefined,
|
||||||
stderr: stderrBuffer,
|
stderr: stderrBuffer,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.strict && exitCode !== 0) {
|
if (options.strict && exitCode !== 0) {
|
||||||
const errorMsg = signal
|
reject(new SmartshellError(options.command, execResult));
|
||||||
? `Command "${options.command}" terminated by signal ${signal}`
|
|
||||||
: `Command "${options.command}" exited with code ${exitCode}`;
|
|
||||||
reject(new Error(errorMsg));
|
|
||||||
} else {
|
} else {
|
||||||
resolve(execResult);
|
resolve(execResult);
|
||||||
}
|
}
|
||||||
@@ -296,25 +341,25 @@ export class Smartshell {
|
|||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
|
await this.killProcessTree(execChildProcess.pid, 'SIGKILL', options.debug);
|
||||||
},
|
},
|
||||||
terminate: async () => {
|
terminate: async () => {
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
|
await this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
|
||||||
},
|
},
|
||||||
keyboardInterrupt: async () => {
|
keyboardInterrupt: async () => {
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
|
await this.killProcessTree(execChildProcess.pid, 'SIGINT', options.debug);
|
||||||
},
|
},
|
||||||
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
|
await this.killProcessTree(execChildProcess.pid, signal, options.debug);
|
||||||
},
|
},
|
||||||
} as IExecResultStreaming;
|
} as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
@@ -344,7 +389,7 @@ export class Smartshell {
|
|||||||
const shellBinary = this.shellEnv.executor === 'bash' ? '/bin/bash' : true;
|
const shellBinary = this.shellEnv.executor === 'bash' ? '/bin/bash' : true;
|
||||||
const execChildProcess = cp.spawn(commandToExecute, [], {
|
const execChildProcess = cp.spawn(commandToExecute, [], {
|
||||||
shell: shellBinary,
|
shell: shellBinary,
|
||||||
cwd: process.cwd(),
|
cwd: options.cwd || process.cwd(),
|
||||||
env: options.env || process.env,
|
env: options.env || process.env,
|
||||||
detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
|
detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
@@ -359,7 +404,7 @@ export class Smartshell {
|
|||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
||||||
}
|
}
|
||||||
execChildProcess.kill('SIGTERM');
|
void this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
|
||||||
}, options.timeout);
|
}, options.timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,18 +504,17 @@ export class Smartshell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
||||||
|
const combinedOutput = shellLogInstance.logStore.toString();
|
||||||
const execResult: IExecResult = {
|
const execResult: IExecResult = {
|
||||||
exitCode,
|
exitCode,
|
||||||
stdout: shellLogInstance.logStore.toString(),
|
stdout: combinedOutput,
|
||||||
|
combinedOutput,
|
||||||
signal: signal || undefined,
|
signal: signal || undefined,
|
||||||
stderr: stderrBuffer,
|
stderr: stderrBuffer,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.strict && exitCode !== 0) {
|
if (options.strict && exitCode !== 0) {
|
||||||
const errorMsg = signal
|
reject(new SmartshellError(options.commandString, execResult));
|
||||||
? `Command "${options.commandString}" terminated by signal ${signal}`
|
|
||||||
: `Command "${options.commandString}" exited with code ${exitCode}`;
|
|
||||||
reject(new Error(errorMsg));
|
|
||||||
} else {
|
} else {
|
||||||
resolve(execResult);
|
resolve(execResult);
|
||||||
}
|
}
|
||||||
@@ -523,25 +567,25 @@ export class Smartshell {
|
|||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
|
await this.killProcessTree(execChildProcess.pid, 'SIGKILL', options.debug);
|
||||||
},
|
},
|
||||||
terminate: async () => {
|
terminate: async () => {
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
|
await this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
|
||||||
},
|
},
|
||||||
keyboardInterrupt: async () => {
|
keyboardInterrupt: async () => {
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
|
await this.killProcessTree(execChildProcess.pid, 'SIGINT', options.debug);
|
||||||
},
|
},
|
||||||
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
||||||
}
|
}
|
||||||
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
|
await this.killProcessTree(execChildProcess.pid, signal, options.debug);
|
||||||
},
|
},
|
||||||
} as IExecResultStreaming;
|
} as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
@@ -550,58 +594,87 @@ export class Smartshell {
|
|||||||
return await childProcessEnded;
|
return await childProcessEnded;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exec(commandString: string): Promise<IExecResult> {
|
private async killProcessTree(
|
||||||
const result = await this._exec({ commandString });
|
pid: number | undefined,
|
||||||
|
signal: plugins.smartexit.TProcessSignal,
|
||||||
|
debug?: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!pid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (debug) {
|
||||||
|
console.log(`[smartshell] Killing process tree ${pid} with ${signal}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await plugins.smartexit.SmartExit.killTreeByPid(pid, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (debug) {
|
||||||
|
console.log(`[smartshell] Tree kill failed for ${pid}: ${error}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(pid, signal as NodeJS.Signals);
|
||||||
|
} catch {
|
||||||
|
// Process already exited or is not accessible.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async exec(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
|
||||||
|
const result = await this._exec({ commandString, ...options });
|
||||||
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
|
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
|
||||||
return result as IExecResult;
|
return result as IExecResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execSilent(commandString: string): Promise<IExecResult> {
|
public async execSilent(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
|
||||||
return (await this._exec({ commandString, silent: true })) as IExecResult;
|
return (await this._exec({ commandString, ...options, silent: true })) as IExecResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execStrict(commandString: string): Promise<IExecResult> {
|
public async execStrict(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
|
||||||
return (await this._exec({ commandString, strict: true })) as IExecResult;
|
return (await this._exec({ commandString, ...options, strict: true })) as IExecResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execStrictSilent(commandString: string): Promise<IExecResult> {
|
public async execStrictSilent(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
|
||||||
return (await this._exec({ commandString, silent: true, strict: true })) as IExecResult;
|
return (await this._exec({ commandString, ...options, silent: true, strict: true })) as IExecResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execStreaming(commandString: string, silent: boolean = false): Promise<IExecResultStreaming> {
|
public async execStreaming(
|
||||||
return (await this._exec({ commandString, silent, streaming: true })) as IExecResultStreaming;
|
commandString: string,
|
||||||
|
silent: boolean = false,
|
||||||
|
options: TExecCommandOptions = {},
|
||||||
|
): Promise<IExecResultStreaming> {
|
||||||
|
return (await this._exec({ commandString, ...options, silent, streaming: true })) as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execStreamingSilent(commandString: string): Promise<IExecResultStreaming> {
|
public async execStreamingSilent(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
|
||||||
return (await this._exec({ commandString, silent: true, streaming: true })) as IExecResultStreaming;
|
return (await this._exec({ commandString, ...options, silent: true, streaming: true })) as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execInteractive(commandString: string): Promise<void> {
|
public async execInteractive(commandString: string, options: TExecCommandOptions = {}): Promise<void> {
|
||||||
await this._exec({ commandString, interactive: true });
|
await this._exec({ commandString, ...options, interactive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execPassthrough(commandString: string): Promise<IExecResult> {
|
public async execPassthrough(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
|
||||||
return await this._exec({ commandString, passthrough: true }) as IExecResult;
|
return await this._exec({ commandString, ...options, passthrough: true }) as IExecResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execStreamingPassthrough(commandString: string): Promise<IExecResultStreaming> {
|
public async execStreamingPassthrough(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
|
||||||
return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming;
|
return await this._exec({ commandString, ...options, streaming: true, passthrough: true }) as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execInteractiveControl(commandString: string): Promise<IExecResultInteractive> {
|
public async execInteractiveControl(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultInteractive> {
|
||||||
return await this._exec({ commandString, interactiveControl: true }) as IExecResultInteractive;
|
return await this._exec({ commandString, ...options, interactiveControl: true }) as IExecResultInteractive;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execStreamingInteractiveControl(commandString: string): Promise<IExecResultStreaming> {
|
public async execStreamingInteractiveControl(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
|
||||||
return await this._exec({ commandString, streaming: true, interactiveControl: true }) as IExecResultStreaming;
|
return await this._exec({ commandString, ...options, streaming: true, interactiveControl: true }) as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execInteractiveControlPty(commandString: string): Promise<IExecResultInteractive> {
|
public async execInteractiveControlPty(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultInteractive> {
|
||||||
return await this._exec({ commandString, interactiveControl: true, usePty: true }) as IExecResultInteractive;
|
return await this._exec({ commandString, ...options, interactiveControl: true, usePty: true }) as IExecResultInteractive;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execStreamingInteractiveControlPty(commandString: string): Promise<IExecResultStreaming> {
|
public async execStreamingInteractiveControlPty(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
|
||||||
return await this._exec({ commandString, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming;
|
return await this._exec({ commandString, ...options, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -618,23 +691,31 @@ export class Smartshell {
|
|||||||
* Executes a command with args array in streaming mode
|
* Executes a command with args array in streaming mode
|
||||||
*/
|
*/
|
||||||
public async execSpawnStreaming(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'streaming'> = {}): Promise<IExecResultStreaming> {
|
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;
|
return await this._execSpawn({ command, args, ...options, streaming: true }) as IExecResultStreaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a command with args array with interactive control
|
* Executes a command with args array with interactive control
|
||||||
*/
|
*/
|
||||||
public async execSpawnInteractiveControl(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'interactiveControl'> = {}): Promise<IExecResultInteractive> {
|
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;
|
return await this._execSpawn({ command, args, ...options, interactiveControl: true }) 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 } = {}
|
options: { timeout?: number; terminateOnMatch?: boolean; cwd?: string; env?: NodeJS.ProcessEnv } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const execStreamingResult = await this.execStreaming(commandString, silent);
|
const execStreamingResult = await this.execStreaming(commandString, silent, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
});
|
||||||
|
const stdout = execStreamingResult.childProcess.stdout;
|
||||||
|
if (!stdout) {
|
||||||
|
await execStreamingResult.terminate();
|
||||||
|
throw new Error('stdout is not available for this process');
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
let matched = false;
|
let matched = false;
|
||||||
@@ -646,7 +727,7 @@ export class Smartshell {
|
|||||||
if (!matched) {
|
if (!matched) {
|
||||||
matched = true;
|
matched = true;
|
||||||
// Remove listener to prevent memory leak
|
// Remove listener to prevent memory leak
|
||||||
execStreamingResult.childProcess.stdout.removeAllListeners('data');
|
stdout.removeAllListeners('data');
|
||||||
await execStreamingResult.terminate();
|
await execStreamingResult.terminate();
|
||||||
reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`));
|
reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`));
|
||||||
}
|
}
|
||||||
@@ -664,7 +745,7 @@ export class Smartshell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove listener to prevent memory leak
|
// Remove listener to prevent memory leak
|
||||||
execStreamingResult.childProcess.stdout.removeListener('data', dataHandler);
|
stdout.removeListener('data', dataHandler);
|
||||||
|
|
||||||
// Terminate process if requested
|
// Terminate process if requested
|
||||||
if (options.terminateOnMatch) {
|
if (options.terminateOnMatch) {
|
||||||
@@ -676,7 +757,7 @@ export class Smartshell {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
execStreamingResult.childProcess.stdout.on('data', dataHandler);
|
stdout.on('data', dataHandler);
|
||||||
|
|
||||||
// Also resolve/reject when process ends
|
// Also resolve/reject when process ends
|
||||||
execStreamingResult.finalPromise.then(() => {
|
execStreamingResult.finalPromise.then(() => {
|
||||||
@@ -699,7 +780,7 @@ export class Smartshell {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise<void> {
|
public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean; cwd?: string; env?: NodeJS.ProcessEnv }): Promise<void> {
|
||||||
return this.execAndWaitForLine(commandString, regex, true, options);
|
return this.execAndWaitForLine(commandString, regex, true, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,7 +850,7 @@ export class Smartshell {
|
|||||||
name: options.ptyTerm || 'xterm-256color',
|
name: options.ptyTerm || 'xterm-256color',
|
||||||
cols: options.ptyCols || 120,
|
cols: options.ptyCols || 120,
|
||||||
rows: options.ptyRows || 30,
|
rows: options.ptyRows || 30,
|
||||||
cwd: process.cwd(),
|
cwd: options.cwd || process.cwd(),
|
||||||
env: options.env || process.env,
|
env: options.env || process.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -784,18 +865,33 @@ export class Smartshell {
|
|||||||
shellLogInstance.addToBuffer(data);
|
shellLogInstance.addToBuffer(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
|
if (options.timeout) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[smartshell] Timeout reached for PTY process ${ptyProcess.pid}, terminating...`);
|
||||||
|
}
|
||||||
|
ptyProcess.kill('SIGTERM');
|
||||||
|
}, options.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap PTY termination into a Promise
|
// Wrap PTY termination into a Promise
|
||||||
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
||||||
ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
this.smartexit.removeProcess({ pid: ptyProcess.pid } as any);
|
this.smartexit.removeProcess({ pid: ptyProcess.pid } as any);
|
||||||
|
|
||||||
|
const combinedOutput = shellLogInstance.logStore.toString();
|
||||||
const execResult: IExecResult = {
|
const execResult: IExecResult = {
|
||||||
exitCode: exitCode ?? (signal ? 1 : 0),
|
exitCode: exitCode ?? (signal ? 1 : 0),
|
||||||
stdout: shellLogInstance.logStore.toString(),
|
stdout: combinedOutput,
|
||||||
|
combinedOutput,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.strict && exitCode !== 0) {
|
if (options.strict && exitCode !== 0) {
|
||||||
reject(new Error(`Command "${options.commandString}" exited with code ${exitCode}`));
|
reject(new SmartshellError(options.commandString, execResult));
|
||||||
} else {
|
} else {
|
||||||
resolve(execResult);
|
resolve(execResult);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user