Compare commits

..

6 Commits

Author SHA1 Message Date
cd5194c365 v3.3.7 2026-03-05 10:18:43 +00:00
71cc64b6d9 fix(smartshell): avoid triple shell nesting, improve WSL path filtering, and use chunked log buffer to reduce memory usage 2026-03-05 10:18:43 +00:00
f254a9e078 v3.3.6 2026-03-04 18:13:53 +00:00
3d6a33f8d2 fix(smartshell): use close event on child processes to ensure exit handling and update dependency versions 2026-03-04 18:13:53 +00:00
d37071dae0 fix(spawn): use detached:true so children are immune to terminal SIGINT
Children now get their own process group. Terminal Ctrl+C only reaches
the parent, which then does orderly tree-kill while children are still
alive and the process tree is intact.
2026-03-04 00:49:29 +00:00
181d352e21 fix(deps): bump smartexit to ^2.0.1 for PID-tracking fix 2026-03-04 00:04:22 +00:00
7 changed files with 3967 additions and 4423 deletions

View File

@@ -1,5 +1,20 @@
# Changelog # Changelog
## 2026-03-05 - 3.3.7 - fix(smartshell)
avoid triple shell nesting, improve WSL path filtering, and use chunked log buffer to reduce memory usage
- Spawn uses the executor shell binary directly (e.g. /bin/bash) and removes extra bash -c wrapping to avoid triple shell nesting
- Only filter out /mnt/c/ and other WSL-specific PATH entries when running under WSL (adds _isWSL detection)
- Replace single concatenated Buffer with chunked buffering in ShellLog (lazy concatenation, logLength property) and update checks to use logLength
- Remove unused dependency "tree-kill" from package.json
## 2026-03-04 - 3.3.6 - fix(smartshell)
use 'close' event on child processes to ensure exit handling and update dependency versions
- Replace child_process 'exit' listeners with 'close' in ts/classes.smartshell.ts (two occurrences) to ensure handlers run after stdio streams are closed.
- Bump devDependencies: @git.zone/tsbuild ^2.7.3 -> ^4.1.2, @git.zone/tsrun ^1.6.2 -> ^2.0.1, @git.zone/tstest ^2.8.3 -> ^3.2.0, @types/node ^22.19.13 -> ^25.3.3.
- Bump dependencies: @push.rocks/smartexit ^2.0.1 -> ^2.0.3, which ^5.0.0 -> ^6.0.1.
## 2026-03-03 - 3.3.2 - fix(release) ## 2026-03-03 - 3.3.2 - fix(release)
add @git.zone/cli release configuration with registries and public access add @git.zone/cli release configuration with registries and public access

View File

@@ -1,7 +1,7 @@
{ {
"name": "@push.rocks/smartshell", "name": "@push.rocks/smartshell",
"private": false, "private": false,
"version": "3.3.3", "version": "3.3.7",
"description": "A library for executing shell commands using promises.", "description": "A library for executing shell commands using promises.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
@@ -33,18 +33,17 @@
}, },
"homepage": "https://code.foss.global/push.rocks/smartshell", "homepage": "https://code.foss.global/push.rocks/smartshell",
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.6.4", "@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsrun": "^1.3.3", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^2.3.2", "@git.zone/tstest": "^3.2.0",
"@types/node": "^22.10.2" "@types/node": "^25.3.3"
}, },
"dependencies": { "dependencies": {
"@push.rocks/smartdelay": "^3.0.1", "@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartexit": "^2.0.0", "@push.rocks/smartexit": "^2.0.3",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@types/which": "^3.0.4", "@types/which": "^3.0.4",
"tree-kill": "^1.2.2", "which": "^6.0.1"
"which": "^5.0.0"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",

8241
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartshell', name: '@push.rocks/smartshell',
version: '3.3.2', version: '3.3.7',
description: 'A library for executing shell commands using promises.' description: 'A library for executing shell commands using promises.'
} }

View File

@@ -28,6 +28,17 @@ export class ShellEnv {
} }
} }
/**
* Detect if running under Windows Subsystem for Linux
*/
private static _isWSL(): boolean {
return !!(
process.env.WSL_DISTRO_NAME ||
process.env.WSLENV ||
(process.platform === 'linux' && process.env.PATH?.includes('/mnt/c/'))
);
}
/** /**
* imports path into the shell from env if available and returns it with * imports path into the shell from env if available and returns it with
*/ */
@@ -39,18 +50,16 @@ export class ShellEnv {
commandPaths = commandPaths.concat(process.env.SMARTSHELL_PATH.split(':')); commandPaths = commandPaths.concat(process.env.SMARTSHELL_PATH.split(':'));
} }
// lets filter for unwanted paths // Only filter out WSL-specific paths when actually running under WSL
// Windows WSL if (ShellEnv._isWSL()) {
commandPaths = commandPaths.filter((commandPathArg) => { commandPaths = commandPaths.filter((commandPathArg) => {
const filterResult = return (
!commandPathArg.startsWith('/mnt/c/') && !commandPathArg.startsWith('/mnt/c/') &&
!commandPathArg.startsWith('Files/1E') && !commandPathArg.startsWith('Files/1E') &&
!commandPathArg.includes(' '); !commandPathArg.includes(' ')
if (!filterResult) { );
// console.log(`${commandPathArg} will be filtered!`); });
} }
return filterResult;
});
commandResult = `PATH=${commandPaths.join(':')} && ${commandStringArg}`; commandResult = `PATH=${commandPaths.join(':')} && ${commandStringArg}`;
return commandResult; return commandResult;
@@ -89,14 +98,12 @@ export class ShellEnv {
} }
pathString += ` && `; pathString += ` && `;
switch (this.executor) { // For both bash and sh executors, build the command string directly.
case 'bash': // The shell nesting is handled by spawn() using the appropriate shell binary.
commandResult = `bash -c '${pathString}${sourceString}${commandArg}'`; // Previously bash executor wrapped in `bash -c '...'` which caused triple
break; // shell nesting (Node spawn sh -> bash -c -> command). Now spawn() uses
case 'sh': // shell: '/bin/bash' directly, so we don't need the extra wrapping.
commandResult = `${pathString}${sourceString}${commandArg}`; commandResult = `${pathString}${sourceString}${commandArg}`;
break;
}
commandResult = this._setPath(commandResult); commandResult = this._setPath(commandResult);
return commandResult; return commandResult;
} }

View File

@@ -5,7 +5,41 @@ import * as plugins from './plugins.js';
* making sure the process doesn't run out of memory * making sure the process doesn't run out of memory
*/ */
export class ShellLog { export class ShellLog {
public logStore = Buffer.from(''); private chunks: Buffer[] = [];
private totalLength = 0;
/**
* Get the accumulated log as a single Buffer.
* Concatenation happens lazily only when accessed.
*/
public get logStore(): Buffer {
if (this.chunks.length === 0) {
return Buffer.alloc(0);
}
if (this.chunks.length === 1) {
return this.chunks[0];
}
// Flatten chunks into a single buffer
const combined = Buffer.concat(this.chunks, this.totalLength);
// Replace chunks array with the single combined buffer for future access
this.chunks = [combined];
return combined;
}
/**
* Set the log store directly (used for truncation).
*/
public set logStore(value: Buffer) {
this.chunks = [value];
this.totalLength = value.length;
}
/**
* Get the current total length of buffered data without concatenating.
*/
public get logLength(): number {
return this.totalLength;
}
/** /**
* log data to console * log data to console
@@ -22,13 +56,9 @@ export class ShellLog {
*/ */
public addToBuffer(dataArg: string | Buffer): void { public addToBuffer(dataArg: string | Buffer): void {
// make sure we have the data as Buffer // make sure we have the data as Buffer
const dataBuffer: Buffer = (() => { const dataBuffer: Buffer = Buffer.isBuffer(dataArg) ? dataArg : Buffer.from(dataArg);
if (!Buffer.isBuffer(dataArg)) { this.chunks.push(dataBuffer);
return Buffer.from(dataArg); this.totalLength += dataBuffer.length;
}
return dataArg;
})();
this.logStore = Buffer.concat([this.logStore, dataBuffer]);
} }
public logAndAdd(dataArg: string | Buffer): void { public logAndAdd(dataArg: string | Buffer): void {

View File

@@ -119,7 +119,7 @@ export class Smartshell {
shell: false, // SECURITY: Never use shell with untrusted input shell: false, // SECURITY: Never use shell with untrusted input
cwd: process.cwd(), cwd: process.cwd(),
env: options.env || process.env, env: options.env || process.env,
detached: false, detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
signal: options.signal, signal: options.signal,
}); });
@@ -182,7 +182,7 @@ export class Smartshell {
if (!bufferExceeded) { if (!bufferExceeded) {
shellLogInstance.addToBuffer(data); shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) { if (shellLogInstance.logLength > maxBuffer) {
bufferExceeded = true; bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
} }
@@ -203,7 +203,7 @@ export class Smartshell {
if (!bufferExceeded) { if (!bufferExceeded) {
shellLogInstance.addToBuffer(data); shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) { if (shellLogInstance.logLength > maxBuffer) {
bufferExceeded = true; bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
} }
@@ -249,7 +249,7 @@ export class Smartshell {
} }
}; };
execChildProcess.once('exit', handleExit); execChildProcess.once('close', handleExit);
execChildProcess.once('error', (error) => { execChildProcess.once('error', (error) => {
if (timeoutHandle) { if (timeoutHandle) {
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);
@@ -338,11 +338,15 @@ export class Smartshell {
return await this._execCommandPty(options, commandToExecute, shellLogInstance); return await this._execCommandPty(options, commandToExecute, shellLogInstance);
} }
// Use the executor's shell binary directly to avoid triple shell nesting.
// Previously: Node spawn(shell:true) → /bin/sh → bash -c → command (3 layers)
// Now: Node spawn(shell:bash) → command (1 layer)
const shellBinary = this.shellEnv.executor === 'bash' ? '/bin/bash' : true;
const execChildProcess = cp.spawn(commandToExecute, [], { const execChildProcess = cp.spawn(commandToExecute, [], {
shell: true, shell: shellBinary,
cwd: process.cwd(), cwd: process.cwd(),
env: options.env || process.env, env: options.env || process.env,
detached: false, detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
signal: options.signal, signal: options.signal,
}); });
@@ -405,7 +409,7 @@ export class Smartshell {
if (!bufferExceeded) { if (!bufferExceeded) {
shellLogInstance.addToBuffer(data); shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) { if (shellLogInstance.logLength > maxBuffer) {
bufferExceeded = true; bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
} }
@@ -426,7 +430,7 @@ export class Smartshell {
if (!bufferExceeded) { if (!bufferExceeded) {
shellLogInstance.addToBuffer(data); shellLogInstance.addToBuffer(data);
if (shellLogInstance.logStore.length > maxBuffer) { if (shellLogInstance.logLength > maxBuffer) {
bufferExceeded = true; bufferExceeded = true;
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
} }
@@ -472,7 +476,7 @@ export class Smartshell {
} }
}; };
execChildProcess.once('exit', handleExit); execChildProcess.once('close', handleExit);
execChildProcess.once('error', (error) => { execChildProcess.once('error', (error) => {
if (timeoutHandle) { if (timeoutHandle) {
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);