UNPKG

@xec-sh/core

Version:

Universal shell execution engine

391 lines 16.3 kB
import { platform } from 'node:os'; import { Readable } from 'node:stream'; import { spawn, spawnSync } from 'node:child_process'; import { StreamHandler } from '../utils/stream.js'; import { RuntimeDetector } from '../utils/runtime-detect.js'; import { CommandError, AdapterError } from '../core/error.js'; import { BaseAdapter } from './base-adapter.js'; export class LocalAdapter extends BaseAdapter { constructor(config = {}) { super(config); this.adapterName = 'local'; this.name = this.adapterName; this.localConfig = config; } async isAvailable() { return true; } async execute(command) { const mergedCommand = this.mergeCommand(command); const startTime = Date.now(); try { const implementation = this.getImplementation(); let result; if (implementation === 'bun' && RuntimeDetector.isBun()) { result = await this.executeBun(mergedCommand); } else { result = await this.executeNode(mergedCommand); } const endTime = Date.now(); return await this.createResult(result.stdout, result.stderr, result.exitCode ?? 0, result.signal ?? undefined, this.buildCommandString(mergedCommand), startTime, endTime, { originalCommand: mergedCommand }); } catch (error) { if (error instanceof CommandError || error instanceof AdapterError) { throw error; } throw new AdapterError(this.adapterName, 'execute', error instanceof Error ? error : new Error(String(error))); } } executeSync(command) { const mergedCommand = this.mergeCommand(command); const startTime = Date.now(); try { const implementation = this.getImplementation(); let result; if (implementation === 'bun' && RuntimeDetector.isBun()) { result = this.executeBunSync(mergedCommand); } else { result = this.executeNodeSync(mergedCommand); } const endTime = Date.now(); return this.createResultSync(result.stdout, result.stderr, result.exitCode ?? 0, result.signal ?? undefined, this.buildCommandString(mergedCommand), startTime, endTime, { originalCommand: mergedCommand }); } catch (error) { if (error instanceof CommandError || error instanceof AdapterError) { throw error; } throw new AdapterError(this.adapterName, 'executeSync', error instanceof Error ? error : new Error(String(error))); } } getImplementation() { if (this.localConfig.forceImplementation) { return this.localConfig.forceImplementation; } if (this.localConfig.preferBun && RuntimeDetector.isBun()) { return 'bun'; } return 'node'; } async executeNode(command) { if (!('stdout' in command) || command.stdout == null) command.stdout = 'pipe'; if (!('stderr' in command) || command.stderr == null) command.stderr = 'pipe'; const progressReporter = this.createProgressReporter(command); const stdoutHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer, onData: progressReporter ? (data) => progressReporter.reportOutput(data) : undefined }); const stderrHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer }); const spawnOptions = this.buildNodeSpawnOptions(command); if (progressReporter) { progressReporter.start(`Executing: ${this.buildCommandString(command)}`); } let child; if (command.shell === true) { const shellCommand = this.buildCommandString(command); child = spawn(shellCommand, [], { ...spawnOptions, shell: true }); } else if (typeof command.shell === 'string') { const shellCommand = this.buildCommandString(command); child = spawn(command.shell, ['-c', shellCommand], { ...spawnOptions, shell: false }); } else { child = spawn(command.command, command.args || [], spawnOptions); } if (command.stdin) { if (typeof command.stdin === 'string' || Buffer.isBuffer(command.stdin)) { child.stdin?.write(command.stdin); child.stdin?.end(); } else if (command.stdin instanceof Readable) { command.stdin.pipe(child.stdin); } } if (command.signal) { const cleanup = () => child.kill(this.localConfig.killSignal); await this.handleAbortSignal(command.signal, cleanup); } let stdoutTransform = null; let stderrTransform = null; if (child.stdout) { if (command.stdout === 'pipe') { stdoutTransform = stdoutHandler.createTransform(); child.stdout.pipe(stdoutTransform); } else if (command.stdout && typeof command.stdout === 'object' && typeof command.stdout.write === 'function') { child.stdout.pipe(command.stdout); } } if (child.stderr) { if (command.stderr === 'pipe') { stderrTransform = stderrHandler.createTransform(); child.stderr.pipe(stderrTransform); } else if (command.stderr && typeof command.stderr === 'object' && typeof command.stderr.write === 'function') { child.stderr.pipe(command.stderr); } } const processPromise = new Promise((resolve, reject) => { child.on('error', (err) => { if (err.code === 'ENOENT') { if (err.syscall === 'spawn /bin/sh' || err.syscall === 'spawn') { if (command.cwd) { err.message = `spawn ${err.path || '/bin/sh'} ENOENT: No such file or directory (cwd: ${command.cwd})`; } else { err.message = `spawn ${err.path || '/bin/sh'} ENOENT: No such file or directory`; } } } if (stdoutTransform) { stdoutTransform.destroy(); } if (stderrTransform) { stderrTransform.destroy(); } if (progressReporter) { progressReporter.error(err); } reject(err); }); child.on('exit', (code, signal) => { if (child.stdout && stdoutTransform) { child.stdout.unpipe(stdoutTransform); } if (child.stderr && stderrTransform) { child.stderr.unpipe(stderrTransform); } if (stdoutTransform) { stdoutTransform.end(); stdoutTransform.destroy(); } if (stderrTransform) { stderrTransform.end(); stderrTransform.destroy(); } if (child.stdout && !child.stdout.destroyed) { child.stdout.destroy(); } if (child.stderr && !child.stderr.destroyed) { child.stderr.destroy(); } if (child.stdin && !child.stdin.destroyed) { child.stdin.destroy(); } if (progressReporter) { if (code === 0) { progressReporter.complete('Command completed successfully'); } else { progressReporter.error(new Error(`Command failed with exit code ${code}`)); } } resolve({ stdout: stdoutHandler.getContent(), stderr: stderrHandler.getContent(), exitCode: code, signal }); }); }); const timeout = command.timeout ?? this.config.defaultTimeout; const result = await this.handleTimeout(processPromise, timeout, this.buildCommandString(command), () => child.kill(this.localConfig.killSignal)); return result; } async executeBun(command) { const Bun = globalThis.Bun; if (!Bun || !Bun.spawn) { throw new AdapterError(this.adapterName, 'execute', new Error('Bun.spawn is not available')); } const progressReporter = this.createProgressReporter(command); const stdoutHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer, onData: progressReporter ? (data) => progressReporter.reportOutput(data) : undefined }); const stderrHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer }); if (progressReporter) { progressReporter.start(`Executing: ${this.buildCommandString(command)}`); } const proc = Bun.spawn({ cmd: [command.command, ...(command.args || [])], cwd: command.cwd, env: this.createCombinedEnv(this.config.defaultEnv, command.env), stdin: this.mapBunStdin(command.stdin), stdout: command.stdout === 'pipe' ? 'pipe' : command.stdout, stderr: command.stderr === 'pipe' ? 'pipe' : command.stderr }); if (command.stdin && (typeof command.stdin === 'string' || Buffer.isBuffer(command.stdin))) { const writer = proc.stdin.getWriter(); await writer.write(typeof command.stdin === 'string' ? new TextEncoder().encode(command.stdin) : command.stdin); await writer.close(); } const stdoutPromise = command.stdout === 'pipe' && proc.stdout ? this.streamBunReadable(proc.stdout, stdoutHandler) : Promise.resolve(); const stderrPromise = command.stderr === 'pipe' && proc.stderr ? this.streamBunReadable(proc.stderr, stderrHandler) : Promise.resolve(); const exitPromise = proc.exited; const timeout = command.timeout ?? this.config.defaultTimeout; const exitCode = await this.handleTimeout(exitPromise, timeout, this.buildCommandString(command), () => proc.kill()); await Promise.all([stdoutPromise, stderrPromise]); if (progressReporter) { if (exitCode === 0) { progressReporter.complete('Command completed successfully'); } else { progressReporter.error(new Error(`Command failed with exit code ${exitCode}`)); } } return { stdout: stdoutHandler.getContent(), stderr: stderrHandler.getContent(), exitCode, signal: null }; } buildNodeSpawnOptions(command) { const options = { cwd: command.cwd, env: this.createCombinedEnv(this.config.defaultEnv, command.env), detached: command.detached, windowsHide: true }; if (command.cwd) { try { require('fs').accessSync(command.cwd, require('fs').constants.F_OK); } catch (err) { } } if (this.localConfig.uid !== undefined) { options.uid = this.localConfig.uid; } if (this.localConfig.gid !== undefined) { options.gid = this.localConfig.gid; } if (command.shell === true) { if (platform() === 'win32') { options.shell = 'cmd.exe'; } else { const availableShells = ['/bin/bash', '/bin/sh', '/usr/bin/bash', '/usr/bin/sh']; let shellFound = false; for (const shell of availableShells) { try { require('fs').accessSync(shell, require('fs').constants.F_OK); options.shell = shell; shellFound = true; break; } catch { } } if (!shellFound) { options.shell = true; } } } else if (typeof command.shell === 'string') { options.shell = command.shell; } else { options.shell = command.shell; } const isStream = (value) => value && typeof value === 'object' && typeof value.write === 'function'; options.stdio = [ command.stdin ? 'pipe' : 'ignore', (typeof command.stdout === 'string' ? command.stdout : 'pipe') || 'pipe', (typeof command.stderr === 'string' ? command.stderr : 'pipe') || 'pipe' ]; return options; } mapBunStdin(stdin) { if (!stdin) return 'ignore'; if (stdin instanceof Readable) return stdin; if (typeof stdin === 'string' || Buffer.isBuffer(stdin)) return 'pipe'; return 'ignore'; } async streamBunReadable(readable, handler) { const reader = readable.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = Buffer.from(value); const transform = handler.createTransform(); await new Promise((resolve, reject) => { transform.on('error', reject); transform.on('finish', resolve); transform.write(chunk); transform.end(); }); } } finally { reader.releaseLock(); } } executeNodeSync(command) { if (!('stdout' in command) || command.stdout == null) command.stdout = 'pipe'; if (!('stderr' in command) || command.stderr == null) command.stderr = 'pipe'; const spawnOptions = this.buildNodeSpawnOptions(command); spawnOptions.encoding = this.config.encoding; let result; if (command.shell === true) { const shellCommand = this.buildCommandString(command); result = spawnSync(shellCommand, [], { ...spawnOptions, shell: true }); } else if (typeof command.shell === 'string') { const shellCommand = this.buildCommandString(command); result = spawnSync(command.shell, ['-c', shellCommand], { ...spawnOptions, shell: false }); } else { result = spawnSync(command.command, command.args || [], spawnOptions); } return { stdout: result.stdout?.toString() || '', stderr: result.stderr?.toString() || '', exitCode: result.status, signal: result.signal }; } executeBunSync(command) { const proc = globalThis.Bun.spawnSync({ cmd: [command.command, ...(command.args || [])], cwd: command.cwd, env: this.createCombinedEnv(this.config.defaultEnv, command.env), stdin: command.stdin && (typeof command.stdin === 'string' || Buffer.isBuffer(command.stdin)) ? command.stdin : undefined, stdout: command.stdout === 'pipe' ? 'pipe' : command.stdout, stderr: command.stderr === 'pipe' ? 'pipe' : command.stderr }); return { stdout: proc.stdout ? new TextDecoder().decode(proc.stdout) : '', stderr: proc.stderr ? new TextDecoder().decode(proc.stderr) : '', exitCode: proc.exitCode, signal: null }; } async dispose() { } } //# sourceMappingURL=local-adapter.js.map