ProcessPromise API
Implementation Reference
Source Files:
packages/core/src/core/process-context.ts- Builds and attaches every ProcessPromise methodpackages/core/src/core/result.ts- TheExecutionResulta ProcessPromise resolves topackages/core/src/types/process.ts-ProcessPromiseandProcessHandletypes
Class: ProcessPromise
A chainable, lazy promise that represents a command. Nothing runs until it's
awaited, iterated with for await, or one of .start() / .spawned /
.child / .pid is read. It extends the native Promise, resolving to an
ExecutionResult — there is no separate ProcessPromise<T> type parameter
and no public constructor; instances are produced by $`cmd`, $.exec()
and equivalents, never built directly.
interface ProcessPromise extends Promise<ExecutionResult> { /* see below */ }
Properties
interface ProcessPromise {
readonly stdin: NodeJS.WritableStream; // writable immediately, buffered until spawn
readonly pid?: number; // reading this starts the command
readonly spawned: Promise<ProcessHandle>; // resolves once actually running
readonly child?: ProcessHandle; // reading this starts the command
exitCode: Promise<number | null>; // null if the process was signalled
}
stdout and stderr are not stream properties on ProcessPromise —
those names are taken by the configurator methods below. To read output as
it arrives, use .child.stdout / .child.stderr (available once .spawned
resolves), or iterate the promise itself with for await.
Chaining Methods
pipe()
Pipe the command's output somewhere else.
pipe(target: PipeTarget, ...values: unknown[]): ProcessPromise
Parameters:
target- A command string, anotherProcessPromise, aCommandobject, aTransform/Writablestream, a line-processor function(line: string) => void, a conditional function(result: ExecutionResult) => Command | string | null, or a template literal (calling.pipeitself as a tag)
Returns: New ProcessPromise for the piped command
Example:
// Simple pipe
await $`cat file.txt`.pipe('grep pattern');
// Multiple pipes
await $`printf 'b\na\nb\n'`
.pipe('sort')
.pipe('uniq');
// With template literals — .pipe itself is a tag
const pattern = 'error';
await $`journalctl -u app`.pipe`grep ${pattern}`;
nothrow()
Prevent throwing on non-zero exit codes.
nothrow(): ProcessPromise
Returns: ProcessPromise that won't throw on error
Example:
// Check if file exists without throwing
const result = await $`test -f file.txt`.nothrow();
if (result.ok) {
console.log('File exists');
} else {
console.log('File does not exist');
}
quiet()
Suppress stdout/stderr output to the terminal (output is still captured on the result).
quiet(): ProcessPromise
Returns: ProcessPromise with suppressed output
Example:
await $`npm install`.quiet();
There is no .verbose() chain method. The equivalent is $.verbose = true
— a setter on the top-level $ (echoes each command, secrets masked, to
stderr before it runs) — or .config.set({ verbose: true }) on any engine
instance.
timeout()
Fail the command after a duration.
timeout(duration: number | string, timeoutSignal?: string): ProcessPromise
Parameters:
duration- Milliseconds, or a duration string such as'30s','5m'timeoutSignal- Signal to send on timeout (default:'SIGTERM')
Returns: ProcessPromise with timeout
Throws: TimeoutError when the timeout fires — this is what the Docker,
Kubernetes and SSH adapters throw, verified by reading their source. The
local adapter, verified empirically, currently throws AdapterError instead
(with the same message and kind: 'timeout') rather than a bare
TimeoutError — so instanceof TimeoutError is not a reliable cross-adapter
check. Test error.kind === 'timeout' instead, which every adapter sets
consistently:
try {
await $`slow-command`.timeout('5s');
} catch (error) {
if (error instanceof ExecutionError && error.kind === 'timeout') {
console.log('Command timed out');
}
}
Example:
// Timeout after 5 seconds
await $`slow-command`.timeout('5s');
// Custom signal on timeout
await $`server`.timeout('10s', 'SIGKILL');
Environment Methods
cwd()
Set working directory.
cwd(path: string): ProcessPromise
Parameters:
path- Working directory path
Returns: ProcessPromise with working directory
Example:
// Run in specific directory
await $`npm build`.cwd('/project');
// Chain with other methods
await $`git pull`
.cwd('/repo')
.quiet()
.timeout('30s');
env()
Set environment variables.
env(variables: Record<string, string>): ProcessPromise
Parameters:
variables- Environment variables object
Returns: ProcessPromise with environment
Example:
// Set environment variables
await $`npm start`.env({
NODE_ENV: 'production',
PORT: '3000'
});
// Merge with the current process's environment
await $`deploy`.env({
...process.env,
API_KEY: 'secret'
});
shell()
Set shell for execution.
shell(shell: string | boolean): ProcessPromise
Parameters:
shell- Shell path, orfalseto execute without a shell
Returns: ProcessPromise with shell configuration
Example:
// Use specific shell
await $`echo $SHELL`.shell('/bin/zsh');
// Disable shell (direct execution)
await $`node script.js`.shell(false);
Input/Output Methods
stdin
Not a method — a writable stream property, available immediately. Writes
are buffered and forwarded once the process actually spawns, so there's no
need to .start() first.
readonly stdin: NodeJS.WritableStream
Example:
// String input
const p = $`cat`;
p.stdin.write('Hello, World!\n');
p.stdin.end();
await p;
// Stream input — pipe a Readable into it directly
fs.createReadStream('input.txt').pipe($`sort`.stdin);
To provide the whole input up front instead, pass it as part of the
Command via .exec() / execute(), or write-then-end as above; there is
no .stdin(input) chain method that takes a value as an argument.
kill()
Kill the process — and everything it spawned. For local, Docker and
Kubernetes this signals the whole process tree, not just the immediate
child, so sh -c 'node server.js' doesn't orphan the server.
kill(signal?: NodeJS.Signals): void
Parameters:
signal- Signal to send (default:'SIGTERM')
Example:
const proc = $`long-running-task`;
// Kill after delay
setTimeout(() => proc.kill(), 5000);
// Kill with specific signal
proc.kill('SIGKILL');
start() / spawned / child / pid
Commands are lazy — nothing runs until the promise is awaited, iterated, or one of these is touched.
start(): ProcessPromise
readonly spawned: Promise<ProcessHandle>
readonly child?: ProcessHandle
readonly pid?: number
start() begins execution without awaiting the result. .spawned resolves
once the command is actually running (spawning is asynchronous — an SSH
"process" needs a connection first). .child and .pid are synchronous
reads that implicitly call .start() — so touching either one begins
execution as a side effect.
ProcessHandle (what .child is, and what .spawned resolves to) is
uniform across environments:
interface ProcessHandle {
readonly pid?: number; // absent for SSH — the remote pid isn't knowable from the channel
readonly stdin: Writable | null;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
kill(signal?: NodeJS.Signals): void;
}
Example:
const p = $`long-server-process`.start();
const handle = await p.spawned;
console.log('pid:', handle.pid);
handle.stdout?.on('data', chunk => console.log(chunk.toString()));
Output Methods
lines()
Get output as an array of lines, once the command finishes. This is a plain
promise, not an async iterable — for await does not work directly on its
return value.
lines(): Promise<string[]>
Example:
const lines = await $`ls -la`.lines();
lines.forEach(line => console.log(line));
To process lines as they arrive rather than after the command finishes,
iterate the ProcessPromise itself — see Async Iteration below.
json()
Parse trimmed output as JSON.
json<T = any>(): Promise<T>
Type Parameters:
T- Expected JSON type
Returns: Promise resolving to parsed JSON
Example:
// Parse JSON output
const data = await $`cat package.json`.json();
console.log(data.name, data.version);
// With type
interface Config {
host: string;
port: number;
}
const config = await $`cat config.json`.json<Config>();
text()
Get trimmed output as a string.
text(): Promise<string>
Returns: Promise resolving to output text
Example:
const content = await $`cat README.md`.text();
console.log(content);
buffer()
Get output as a Buffer. Called this way — directly on the ProcessPromise,
before awaiting — it re-encodes the already-decoded stdout string, so it
is not safe for binary data.
buffer(): Promise<Buffer>
Example:
const lossy = await $`cat image.png`.buffer(); // corrupts binary output
const exact = (await $`cat image.png`).buffer(); // the awaited result's buffer() — binary-safe
fs.writeFileSync('copy.png', exact);
cache()
Cache the result of this one command. Keyed by command + working directory + environment + target (host/container/pod), so identical commands against different machines never collide.
cache(options?: { key?: string; ttl?: number; invalidateOn?: string[] }): ProcessPromise
Parameters:
options.key- Override the computed cache keyoptions.ttl- Time to live in milliseconds (default: 60000)options.invalidateOn- Glob-style patterns matched against other cache keys, evicted on a successful run
Example:
const result = await $`curl https://api.example.com/data`.cache({ ttl: 5000 });
Advanced Methods
retry
There is no .retry() method on ProcessPromise. Two real alternatives
exist instead, both taking the same RetryOptions:
interface RetryOptions {
maxRetries?: number; // default 3
initialDelay?: number; // default 100ms
maxDelay?: number; // default 30000ms
backoffMultiplier?: number; // default 2
jitter?: boolean; // default true
isRetryable?: (result: ExecutionResult) => boolean;
onRetry?: (attempt: number, result: ExecutionResult, nextDelay: number) => void;
}
The standalone retry() export wraps a single call and retries it while it
throws a CommandError (a non-CommandError throw is treated as a bug and
propagates immediately). Exhaustion throws RetryError, carrying every
attempt's result:
import { retry } from '@xec-sh/core';
const result = await retry(() => $`flaky-command`, { maxRetries: 5, initialDelay: 1000 });
$.retry(options) is an engine-chaining method — it returns a new
engine that applies retry to every command run through it, not a
ProcessPromise method:
await $.retry({ maxRetries: 5 })`network-request`;
signal()
Provide an abort signal.
signal(signal: AbortSignal): ProcessPromise
Parameters:
signal- AbortSignal for cancellation
Example:
const controller = new AbortController();
// Cancel after timeout
setTimeout(() => controller.abort(), 5000);
try {
await $`long-task`.signal(controller.signal);
} catch (error) {
// Aborting kills the process, so this is a CommandError carrying the
// signal it died from — not a generic AbortError.
if (error instanceof CommandError && error.signal) {
console.log('Cancelled:', error.signal);
}
}
Async iteration
A ProcessPromise is itself async-iterable, streaming lines as they arrive
rather than waiting for the command to finish — this is what makes it work
for a follow-style command:
for await (const line of $`tail -f log.txt`) {
console.log('Log:', line);
}
There is no .tee(), .pipeStdout() or .pipeStderr() method. Setting
.stdout(customWritable) redirects output to that stream instead of
capturing it on the result — it does not duplicate output to both places —
so there's no built-in way to write to a file while also keeping the
captured string; write both sides explicitly, e.g. by piping to a
fs.createWriteStream() and separately reading .child.stdout.
Result Properties
ExecutionResult
The object an awaited ProcessPromise resolves to:
interface ExecutionResult {
stdout: string;
stderr: string;
stdall: string; // stdout and stderr merged in arrival order
exitCode: number;
signal?: string;
ok: boolean; // exitCode === 0 && !signal
cause?: string; // set when !ok
command: string;
duration: number;
startedAt: Date;
finishedAt: Date;
adapter: string;
host?: string;
container?: string;
toMetadata(): object;
throwIfFailed(): void;
text(): string;
json<T = any>(): T;
lines(): string[];
buffer(): Buffer; // exact original bytes — binary-safe
}
Example:
const result = await $`echo hello`.nothrow();
console.log(result.stdout); // "hello\n"
console.log(result.stderr); // ""
console.log(result.exitCode); // 0
console.log(result.ok); // true
console.log(result.duration); // e.g. 15
There is no separate ProcessOutput type — a ProcessOutput class exists
in the package's source but is dead code, never instantiated or exported;
ExecutionResult is what every command actually resolves to.
Error Handling
CommandError
Thrown when a command exits non-zero (unless using nothrow()). There is
no ProcessError class — the real name is CommandError, and it extends
the base ExecutionError:
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 the command, if captured
}
Example:
try {
await $`exit 1`;
} catch (error) {
if (error instanceof CommandError) {
console.log('Exit code:', error.exitCode);
console.log('Error output:', error.stderr);
console.log('Duration:', error.duration, 'ms');
}
}
Performance Characteristics
Measured on the built dist, not estimated (see the project's CLAUDE.md
for the full breakdown):
- Command creation: ~6µs
- Pipe setup: ~12µs
- Simple execution: <5ms overhead over the process spawn itself
- Memory overhead: <5MB per command
There is no fixed "base ProcessPromise" allocation figure published beyond
this — command creation already accounts for attaching every chain method.
The default maxBuffer cap is unset at the Command level (no truncation)
unless configured; when set, exceeding it throws MaxBufferExceededError
rather than silently truncating.
Usage Patterns
Sequential Execution
// Chain commands sequentially
await $`npm install`;
await $`npm test`;
await $`npm build`;
Parallel Execution
// Run commands in parallel
const [install, lint, test] = await Promise.all([
$`npm install`.quiet(),
$`npm run lint`.nothrow(),
$`npm test`.timeout('60s')
]);
Error Recovery
// Try primary, fallback to secondary
const result = await $`primary-command`.nothrow();
if (!result.ok) {
await $`fallback-command`;
}
Stream Processing
// Process a live stream of filenames as they're found
for await (const file of $`find . -name "*.log"`) {
const size = await $`stat -f%z ${file}`.text();
console.log(`${file}: ${size} bytes`);
}
Related Documentation
- Execution Engine - Engine that creates ProcessPromise
- Types - TypeScript type definitions
- Error Handling - Error handling patterns
- Stream Processing - Stream patterns