Skip to main content

Local Target Troubleshooting

Command not found

With the default shell (shell: true), a missing command is not a JavaScript exception — the shell runs, doesn't find the command, and exits 127:

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

try {
await $`does-not-exist`;
} catch (error) {
if (error instanceof CommandError) {
error.exitCode; // 127
error.stderr; // "...: does-not-exist: command not found" (wording varies by shell)
error.message; // already includes "127 (command not found)"
}
}

CommandError looks up common exit codes and appends their meaning to the message, so you don't need your own lookup table for the frequent ones (127, 126, 137, 139, ...; the full list is below).

const path = await $.which('command-name'); // null if not found
await $.env({ PATH: `/usr/local/bin:${process.env.PATH}` })`command-name`;
await $`/usr/local/bin/command-name`; // full path

On macOS, Homebrew binaries under /opt/homebrew/bin are a common case of the same issue if that directory isn't already on PATH. A downloaded script may also need its quarantine attribute removed before it will execute: await $`xattr -d com.apple.quarantine ./script.sh`.

Permission denied

Exit code 126 means the file exists but isn't executable:

await $`chmod +x ./script.sh`;
await $`./script.sh`;

// Or invoke the interpreter directly, which doesn't need +x
await $`bash script.sh`;

Working directory does not exist

import { existsSync, mkdirSync } from 'node:fs';

if (!existsSync('/project')) mkdirSync('/project', { recursive: true });
await $.cd('/project')`npm install`;

A cwd that doesn't exist fails when the process is spawned, not before — the error surfaces as a failed spawn rather than a dedicated "bad directory" error.

Shell syntax errors

Bash-only syntax ([[ ]], arrays) fails under /bin/sh. See Portability for the POSIX-safe equivalents.

Environment variables

Commands always inherit process.env; env layers additional keys on top of it — there's no way to start a command with a fully empty environment.

await $`echo $HOME`; // inherited
await $.env({ MY_VAR: 'value' })`echo $MY_VAR`; // added

If a variable you expect isn't set, check that it survives shell startup — see Startup files.

Buffer and encoding

Set maxBuffer process-wide, or on a derived engine for the commands that need a different cap:

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

configure({ maxBuffer: 100 * 1024 * 1024 }); // every command after this

const generous = $.with({ maxBuffer: 500 * 1024 * 1024 });
await generous`pg_dump mydb`; // only this one

encoding remains engine-level: set it through configure().

Exceeding maxBuffer (default 10MB) kills the process and rejects with MaxBufferExceededError, carrying whatever was collected before the cut-off:

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

try {
await $`cat huge-file`;
} catch (error) {
if (error instanceof MaxBufferExceededError) {
error.limit; // bytes
error.partialStdout; // output collected before the kill
}
}

result.stdout is always decoded text, which is lossy for arbitrary bytes. For binary output, read .buffer() on the awaited result instead — it returns the exact bytes written:

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

Timeouts

Commands time out after 30 seconds by default:

await $`slow-command`.timeout(0); // disable
await $`slow-command`.timeout('5m'); // duration string
await $`slow-command`.timeout(300000); // same, in milliseconds

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

try {
await $`potentially-slow-command`.timeout(5000);
} catch (error) {
if (error instanceof TimeoutError) { /* ... */ }
}

A timeout kills the whole process tree (see below), using the adapter's killSignal — configured once when the engine is created (default SIGTERM), not the second argument to .timeout(duration, signal), which local execution doesn't consult.

Killing a command and its children

Local commands run in their own process group by default. .kill() — whether called directly, via a timeout, or via a maxBuffer overflow — signals that whole group plus any descendant that escaped it, so a shell wrapper (sh -c 'node server.js') can't orphan what it started:

const p = $`long-running-build`;
process.on('SIGINT', () => p.kill('SIGTERM'));

Exit codes

CommandError.exitCode is the raw exit code; the error message already explains the common ones:

CodeMeaning
2misuse of shell builtins
126found but not executable
127command not found
130SIGINT (Ctrl-C)
134SIGABRT
137SIGKILL, often an OOM kill
139segmentation fault
141SIGPIPE, reader closed early
143SIGTERM

Error types

ClassThrown when
CommandErrornon-zero exit code — has .exitCode, .signal, .stdout, .stderr, .command, .duration
TimeoutError.timeout() elapsed
MaxBufferExceededErrorstdout/stderr exceeded maxBuffer
AdapterErrorthe process couldn't be spawned at all (bad cwd, bad shell path, ...)

All of them extend ExecutionError, which carries a stable .kind (e.g. 'command-failed', 'timeout') for branching that doesn't depend on message wording, and a .recoverable flag.

import { CommandError, TimeoutError } from '@xec-sh/core';

try {
await $`deploy.sh`;
} catch (error) {
if (error instanceof TimeoutError) { /* ... */ }
else if (error instanceof CommandError) { /* ... */ }
else throw error;
}

Skip exceptions entirely with .nothrow():

const result = await $`deploy.sh`.nothrow();
if (!result.ok) {
console.error(result.exitCode, result.stderr);
}

Debugging a command

Echo every command as it runs:

$.verbose = true; // writes "$ <command>" to stderr, credentials masked

result.stdall gives stdout and stderr merged in the order they actually arrived — useful when what matters is which step logged a given line, which separate stdout/stderr strings lose:

const result = await $`build.sh`.nothrow();
console.log(result.stdall);

Listen for command lifecycle events, e.g. to log everything a script runs:

$.on('command:start', ({ command, cwd }) => console.log('>', command, cwd));
$.on('command:complete', ({ command, exitCode, duration }) =>
console.log('<', command, exitCode, `${duration}ms`));
$.on('command:error', ({ command, error }) => console.error('!', command, error));

A failing command's error message already names where it was called from (at file.ts:42), so there's no need to build your own call-stack tracking.

For a command you need to watch while it runs rather than after it finishes, see live process access.spawned, .child and .pid give real stdout/stderr streams and a pid without waiting for completion.