Skip to main content

Local Target Overview

Local targets run commands directly on the machine executing the script, through Node's child_process (or Bun.spawn, when the adapter is configured to prefer it). It is the default target — no configuration is required to use it.

import { $ } from '@xec-sh/core';

const greeting = await $`echo "Hello, World!"`.text();
console.log(greeting); // "Hello, World!"

Target configuration

# .xec/config.yaml
targets:
local:
type: local
shell: /bin/bash # optional: path to a shell binary, or false to skip the shell
env:
NODE_ENV: development
cwd: /project

All fields are optional; local also accepts timeout, encoding, maxBuffer and throwOnNonZeroExit. shell takes a boolean or the path to a single executable — it cannot carry extra arguments (see Shell Configuration).

Execution model

LocalAdapter (packages/core/src/adapters/local/index.ts) picks Bun.spawn or Node's child_process.spawn per command. The choice, and a few OS-level settings, are constructor-only — set them once when the engine is created, not per command:

import { configure, RuntimeDetector } from '@xec-sh/core';

configure({
adapters: {
local: {
preferBun: true, // use Bun.spawn when the process is running under Bun
forceImplementation: 'bun', // or 'node' — skip detection entirely
uid: 1000, // Unix only
gid: 1000, // Unix only
killSignal: 'SIGKILL', // used for timeouts, buffer overflow and tree-kill (default SIGTERM)
},
},
});

RuntimeDetector.isBun(); // true when the current process is Bun

configure() replaces the default $. To run specific commands through an independently configured engine instead:

import { ExecutionEngine, createCallableEngine } from '@xec-sh/core';

const bunOnly = createCallableEngine(
new ExecutionEngine({ adapters: { local: { forceImplementation: 'bun' } } }),
);
await bunOnly`echo hello`;

Environment variables

Commands inherit process.env; anything passed as env is layered on top, per key — there is no way to run a command with a fully empty environment.

await $`echo $HOME`; // current HOME

await $.env({ NODE_ENV: 'production' })`npm run build`; // persists on the returned engine

await $.with({ env: { DEBUG: 'app:*' } })`node app.js`; // this command only

Working directory

$.pwd(); // directory the engine currently uses
const proj = $.cd('/project'); // returns a new engine rooted there
await proj`npm install`;

await $`pwd`.cwd('/tmp'); // override for a single command -> /tmp

import { within } from '@xec-sh/core';
await within('/project', async () => {
await $`npm install`;
await $`npm test`;
}); // reverts afterward

Reading output

const result = await $`ls -la`;
result.stdout; // string
result.stderr; // string
result.stdall; // stdout and stderr merged in the order they arrived
result.exitCode;
result.ok; // exitCode === 0 && no signal

Convenience readers, available directly on the pending command:

await $`echo hi`.text(); // trimmed stdout
await $`echo '{"a":1}'`.json();
await $`printf 'a\nb\n'`.lines(); // ['a', 'b']

Binary output needs the awaited result's .buffer(), which returns the exact bytes written rather than a re-encoded string:

const image = await $`cat photo.png`;
const bytes = image.buffer();

Stream a long-running command line by line as it runs:

for await (const line of $`tail -f app.log`) {
console.log(line);
}

Pipe to another command or to a stream:

await $`cat file.txt`.pipe($`grep pattern`).pipe($`wc -l`);
await $`npm install`.pipe(process.stdout);

import { createWriteStream } from 'node:fs';
await $`echo content`.pipe(createWriteStream('output.txt'));

Provide input:

await $.with({ stdin: 'hello\n' })`cat`;

import { createReadStream } from 'node:fs';
await $.with({ stdin: createReadStream('input.txt') })`wc -l`;

Live process access

$`cmd` is lazy — nothing runs until it is awaited, started, or one of the live accessors below is read.

const p = $`sleep 100`;
p.start(); // begins running, without awaiting

const handle = await p.spawned; // resolves once the process exists
p.pid; // process id (starts the command if it hasn't already)
p.child; // ProcessHandle: pid, stdin, stdout, stderr, kill()

p.stdin.write('input\n'); // writable immediately; buffered until the process exists
p.stdin.end();

p.kill('SIGTERM'); // signals the whole process tree, not just the direct child
await p.nothrow();

On POSIX, a command runs in its own process group by default (detached: true unless overridden), and .kill() signals that group plus any descendant that escaped it — a shell wrapper like sh -c 'node server.js' cannot orphan what it started.

Timeouts

await $`npm install`.timeout(60000);
await $`npm install`.timeout('60s');

Commands time out after 30 seconds by default. A timeout rejects with TimeoutError and kills the process tree, using the adapter's killSignal (default SIGTERM, set at construction as shown above).

Errors

A non-zero exit code throws CommandError (exitCode, signal, stdout, stderr, command, duration) by default. Opt out per command with .nothrow():

const result = await $`test -f file.txt`.nothrow();
if (!result.ok) {
console.error(result.stderr);
}

See Troubleshooting for exit-code meanings and common failures.