Adapter Concept
Adapters are a key component of the Xec architecture, providing command execution in various environments through a unified API. Each adapter encapsulates the specifics of a particular environment while providing a universal interface.
Adapter System Architecture
The engine routes each command through the BaseAdapter interface to one of the five concrete adapters — four environment adapters plus a mock used in tests:
Base Adapter Class
All adapters inherit from BaseAdapter. It is real — every adapter you read
about below is built on it — but it is an internal class, not exported from
@xec-sh/core. The sections on this page describe it to explain how the
adapters behave, not as an API you can import; see Creating Your Own
Adapter below for what that means in practice.
abstract class BaseAdapter extends EnhancedEventEmitter implements Disposable {
protected config: ResolvedBaseAdapterConfig; // BaseAdapterConfig with defaults filled in
protected abstract readonly adapterName: string;
// Main execution method
abstract execute(command: Command): Promise<ExecutionResult>;
// Availability check
abstract isAvailable(): Promise<boolean>;
// Resource cleanup
abstract dispose(): Promise<void>;
// Optional synchronous version — LocalAdapter implements this; others don't
executeSync?(command: Command): ExecutionResult;
}
Adapter Configuration
interface BaseAdapterConfig {
defaultTimeout?: number; // Default timeout, in ms (default: 120000)
defaultCwd?: string; // Working directory
defaultEnv?: Record<string, string>; // Environment variables
defaultShell?: string | boolean; // Shell for execution
encoding?: BufferEncoding; // Output encoding
maxBuffer?: number; // Maximum buffer size, in bytes (default: 10MB)
throwOnNonZeroExit?: boolean; // Throw exception on error (default: true)
sensitiveDataMasking?: { // Data masking (all fields optional; defaults on)
enabled?: boolean;
patterns?: RegExp[];
replacement?: string;
};
}
Execution Lifecycle
1. Adapter Selection
// Explicit selection
await $.ssh({ host: 'server' })`ls`;
// Through configuration
await $.with({
adapter: 'docker',
adapterOptions: { container: 'app' }
})`ls`;
// Automatic selection
await $`ls`; // Uses LocalAdapter
2. Command Preparation
Every adapter fills a command in with its own defaults before running it.
timeout accepts either milliseconds or a duration string ('30s'); this is
where it gets resolved to a number, so nothing downstream has to parse it
again:
protected mergeCommand(command: Command): ResolvedCommand {
const timeout = command.timeout ?? this.config.defaultTimeout;
return {
...command,
cwd: command.cwd ?? this.config.defaultCwd,
env: { ...this.config.defaultEnv, ...command.env },
timeout: timeout === undefined ? undefined : parseDuration(timeout),
shell: command.shell ?? this.config.defaultShell,
maxBuffer: command.maxBuffer ?? this.config.maxBuffer,
throwOnNonZeroExit: command.throwOnNonZeroExit ?? this.config.throwOnNonZeroExit
};
}
3. Execution
// Each adapter implements its own logic
async execute(command: Command): Promise<ExecutionResult> {
const merged = this.mergeCommand(command);
const startTime = Date.now();
// Adapter-specific implementation
const result = await this.runInEnvironment(merged);
// Creating the unified result — masks sensitive data in stdout/stderr/command,
// and throws automatically when the command failed and nothing downstream
// (like ProcessPromise) is going to make that decision instead.
return this.createResult(
result.stdout,
result.stderr,
result.exitCode,
result.signal,
this.buildCommandString(merged),
startTime,
Date.now()
);
}
4. Result Processing
The result every adapter produces is the same ExecutionResult a caller sees
after await $\cmd``:
interface ExecutionResult {
stdout: string; // Standard output
stderr: string; // Error output
stdall: string; // stdout and stderr merged in arrival order
exitCode: number; // Exit code
signal?: string; // Termination signal
ok: boolean; // exitCode === 0 && !signal
cause?: string; // Why it failed, when not ok
duration: number; // Execution time (ms)
startedAt: Date; // Start time
finishedAt: Date; // Finish time
adapter: string; // Used adapter
host?: string; // Host (for SSH)
container?: string; // Container (for Docker)
toMetadata(): object;
throwIfFailed(): void;
text(): string;
json<T = any>(): T;
lines(): string[];
buffer(): Buffer; // Exact bytes, binary-safe
}
Adapter Types
LocalAdapter
Command execution in the local system:
const local = $.local();
await local`ls -la`;
Features:
- Direct execution via child_process
- Bun runtime support
- Synchronous execution
- Minimal overhead
SSHAdapter
Command execution on remote servers:
const ssh = $.ssh({
host: 'server.com',
username: 'user',
privateKey: '/path/to/key'
});
await ssh`ls -la`;
Features:
- SSH connection pool
- SSH tunnels
- File transfer (SCP/SFTP)
- Sudo support
DockerAdapter
Command execution in Docker containers:
const docker = $.docker({
container: 'my-app'
});
await docker`ls -la`;
Features:
- Container lifecycle management
- Log streaming
- Volume mounting
- Docker Compose integration
KubernetesAdapter
Command execution in Kubernetes pods:
const k8s = $.k8s('my-pod'); // or $.k8s({ pod: 'my-pod' })
await k8s`ls -la`;
// Pod object API (not directly callable — use .exec)
await $.k8s().pod('my-pod').exec`ls -la`;
Features:
- Port forwarding
- Container logs
- File copying
- Namespace support
Remote Docker (via SSH Composition)
For Docker on remote hosts, compose SSH and Docker manually:
const $ssh = $.ssh({ host: 'server', username: 'user' });
await $ssh`docker exec app ls -la`;
Common Adapter Capabilities
Stream Processing
// StreamHandler for all adapters
protected createStreamHandler(options?: {
onData?: (chunk: string) => void
}): StreamHandler {
return new StreamHandler({
encoding: this.config.encoding,
maxBuffer: this.config.maxBuffer,
onData: options?.onData
});
}
Sensitive Data Masking
// Automatic password and key hiding
protected maskSensitiveData(text: string): string {
if (!this.config.sensitiveDataMasking.enabled) {
return text;
}
for (const pattern of this.config.sensitiveDataMasking.patterns) {
text = text.replace(pattern, this.config.sensitiveDataMasking.replacement);
}
return text;
}
Masking Examples:
// Passwords
"password=secret123" → "password=[REDACTED]"
// API keys
"api_key: abc123" → "api_key: [REDACTED]"
// Bearer tokens
"Authorization: Bearer xyz789" → "Authorization: Bearer [REDACTED]"
// SSH keys
"-----BEGIN RSA PRIVATE KEY-----..." → "[REDACTED]"
Timeout Handling
protected async handleTimeout(
promise: Promise<any>,
timeout: number,
command: string,
cleanup?: () => void
): Promise<any> {
if (timeout <= 0) return promise;
const timeoutPromise = new Promise((_, reject) => {
const timer = setTimeout(() => {
if (cleanup) cleanup();
reject(new TimeoutError(command, timeout));
}, timeout);
promise.finally(() => clearTimeout(timer));
});
return Promise.race([promise, timeoutPromise]);
}
Adapter Events
// Each adapter can generate events
$.on('connection:open', ({ host, type }) => {
console.log(`Connected to ${host} (${type})`);
});
$.on('transfer:complete', ({ source, destination, bytesTransferred }) => {
console.log(`Transferred ${bytesTransferred} bytes: ${source} -> ${destination}`);
});
$.on('docker:run', ({ image, container }) => {
console.log(`Container ${container} started from ${image}`);
});
Creating Your Own Adapter
BaseAdapter is not exported from @xec-sh/core, so a custom adapter cannot
currently be written outside the package — there is no base class to extend
it from. ExecutionEngine.registerAdapter(name, adapter: BaseAdapter) is
real, but its parameter type is BaseAdapter itself, which is both
unimported and abstract with protected members, so nothing built outside the
package can satisfy it without an unsafe cast — and even then, the object
would still need to correctly implement everything BaseAdapter gives an
in-package adapter for free (timeout/cwd/env defaulting, sensitive-data
masking, buffered streaming with a size cap, retry, and the call-site capture
that names a failure's location) by hand.
If you need an environment none of local, ssh, docker or kubernetes
cover, the supported path today is composition: shell out to whatever CLI or
API reaches that environment from inside a command run through an existing
adapter, the way Remote Docker above
composes SSH and Docker.
Resource Management
Connection Pools
SSH and other network adapters use pools:
class ConnectionPool {
private connections = new Map<string, Connection>();
private maxConnections = 10;
private ttl = 300000; // 5 minutes
async getConnection(key: string): Promise<Connection> {
// Reuse existing
if (this.connections.has(key)) {
return this.connections.get(key)!;
}
// Create new
const conn = await this.createConnection();
this.connections.set(key, conn);
// Auto-cleanup by TTL
setTimeout(() => {
this.closeConnection(key);
}, this.ttl);
return conn;
}
}
Lazy Initialization
Adapters are created only when needed:
class ExecutionEngine {
private adapters = new Map<string, BaseAdapter>();
private async selectAdapter(command: Command): Promise<BaseAdapter> {
const type = command.adapter || 'local';
// Create on first use
if (!this.adapters.has(type)) {
this.adapters.set(type, this.createAdapter(type));
}
return this.adapters.get(type)!;
}
}
Error Handling
Error Types
AdapterError, CommandError and TimeoutError are real, exported error
classes, all extending ExecutionError (which carries a machine-readable
kind and a recoverable flag — see the API reference
for the full classification). The fields relevant to a catch block:
class CommandError extends ExecutionError {
readonly command: string;
readonly exitCode: number;
readonly signal: string | undefined;
readonly stdout: string;
readonly stderr: string;
readonly duration: number;
readonly callSite: string; // where the caller wrote this command, when captured
}
class TimeoutError extends ExecutionError {
readonly command: string;
readonly timeout: number;
}
class AdapterError extends ExecutionError {
readonly adapter: string;
readonly operation: string;
readonly originalError?: Error;
}
A CommandError's message already includes the sanitized command, what the
exit code usually means (explainExitCode), the first few lines of stderr
and the call site — the fields above are for programmatic handling, not for
reconstructing the message yourself.
Handling Strategies
// Automatic retry
async executeWithRetry(command: Command): Promise<ExecutionResult> {
let lastError;
for (let i = 0; i < 3; i++) {
try {
return await this.execute(command);
} catch (error) {
lastError = error;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
}
}
throw lastError;
}
// Fallback to another adapter
async executeWithFallback(command: Command): Promise<ExecutionResult> {
try {
return await this.primaryAdapter.execute(command);
} catch {
return await this.fallbackAdapter.execute(command);
}
}
Performance
Adapter Metrics
interface AdapterMetrics {
totalExecutions: number;
averageDuration: number;
errorRate: number;
activeConnections: number;
cacheHitRate: number;
}
// Metric collection
adapter.on('command:complete', ({ duration }) => {
metrics.totalExecutions++;
metrics.averageDuration =
(metrics.averageDuration * (metrics.totalExecutions - 1) + duration) /
metrics.totalExecutions;
});
Optimizations
- Result caching - for idempotent commands
- Connection pools - connection reuse
- Stream processing - for large outputs
- Parallel execution - for independent commands
- Lazy loading - creation on demand
Conclusion
The adapter system in Xec provides:
- Universality: unified API for all environments
- Extensibility: easy addition of new adapters
- Security: sensitive data masking
- Performance: optimizations for each environment
- Reliability: error handling and recovery
Adapters are the foundation for creating powerful automation tools that work in any environment.