One TypeScript API for commands, wherever they run — local shell, SSH host, Docker container, Kubernetes pod.
import { $ } from '@xec-sh/core';
const app = $.ssh('deploy@prod-1'); // an SSH host
const db = $.docker('postgres-main'); // a container
const api = $.k8s('production/api-7f9d'); // a pod
const here = $; // this machine
// The same command, the same API, the same result shape — everywhere.
await here`pnpm build`;
await app`systemctl restart app`;
await db`pg_dump -U app mydb`.pipe`gzip`;
await api.cd('/var/log').env({ LINES: '200' })`tail -n $LINES app.log`;To run a command somewhere other than your own machine, Node projects hand-assemble four libraries with four APIs, four error models, four streaming models:
| Where | What people use | The API you learn |
|---|---|---|
| local | execa / zx |
template literals, results |
| SSH | ssh2 |
connections, channels, callbacks |
| Docker | dockerode |
exec instances, demuxed streams |
| Kubernetes | @kubernetes/client-node |
informers, watch, exec websockets |
The code that "just restarts a service" looks completely different depending on where
that service lives — and moving it from a container to a host rewrites everything,
though the command is the same. zx, execa, dax and Bun Shell all stop at the local
machine. Xec is the same $ across all four.
Each of these is enforced by a test in this repository.
- Interpolation is safe by default.
$`rm ${userInput}`quotes the value; it cannot alter the structure of the command.$.rawexists for when you mean it. - An option either works or fails loudly.
.cd()on a container changes the directory in the container..env()on a pod exports in the pod — and never leaks into the local process environment.AbortSignalcancels on every adapter. - No silent data loss. Output over
maxBufferkills the producer and fails with the truncated head preserved — never an empty result with exit code 0. A process killed by a signal is neverok. - Failures explain themselves. The error message carries the exit code and the
head of stderr.
result.ok,result.cause, typedFailureKindfor programmatic handling. - Secrets stay out of logs. Command echoes, events, and error messages pass through the same masking rules — tokens, keys, URL credentials, PEM blocks.
- Results read like strings.
`Branch: ${await $`git branch --show-current`}`works the way$(...)works in a shell. - Connections are pooled. SSH reuses authenticated connections, reconnects on drop, and cleans up on dispose.
// Each of these returns a new immutable context.
const staging = $.ssh('deploy@staging')
.cd('/srv/app')
.env({ NODE_ENV: 'staging' })
.timeout(60_000)
.retry({ maxRetries: 3 });
await staging`pnpm migrate`;
// The identical chain works on a pod:
const pod = $.k8s('staging/api').cd('/srv/app').env({ NODE_ENV: 'staging' });
await pod`node healthcheck.js`;
// Results are structured:
const result = await staging`git rev-parse HEAD`.nothrow();
result.ok // boolean — exit 0 and no signal
result.stdout // string
result.exitCode // number (128+signal for signalled processes)
result.cause // why not ok
// Or go straight to the shape you need:
const pkg = await $`cat package.json`.json<{ version: string }>();
const files = await $`ls -1`.lines();
// Environment-specific power stays available:
await $.ssh('deploy@prod').tunnel({ localPort: 5432, remoteHost: 'db', remotePort: 5432 });
await $.k8s('production/api').pod('api-7f9d').portForward(8080, 80);
await $.k8s('production/api').pod('api-7f9d').follow(line => audit(line));| Package | What it is |
|---|---|
| @xec-sh/core | The execution engine: $, adapters, pooling, masking. One runtime dependency (ssh2), loaded only when an SSH target is used. |
| @xec-sh/cli | xec command: run scripts and tasks against configured targets. |
| @xec-sh/ops | Deploy strategies, pipelines, health checks, secrets, discovery. |
| @xec-sh/kit | Terminal UI: prompts, spinners, tables, colors. |
| @xec-sh/loader | TypeScript script loading, CDN modules with integrity pinning, REPL. |
| @xec-sh/testing | Docker/SSH/kind fixtures for testing against real environments. |
pnpm add @xec-sh/core # the engine
pnpm add -g @xec-sh/cli # the CLIRuns on Node 22.18+, Bun, and Deno — the same commands, byte-identical results, pinned by a cross-runtime parity test. The libraries alone need only Node 20.
xec on deploy@prod-1 'systemctl restart app' # SSH
xec in postgres-main 'pg_dump mydb' # Docker container by name
xec in pods.api 'cat /var/log/app.log' # Kubernetes pod from config
xec run script.ts # TypeScript script with $ in scope
xec forward hosts.prod 8080:80 # port forwarding, incl. reverse (-r)Targets, defaults and tasks live in .xec/config.yaml; scripts get the same $ API.
Linux, macOS and Windows; the unit suite runs on Windows in CI. Escaping,
path handling, glob separators, line endings and process termination behave
identically on all three — what a command means is still the shell's, and
cmd.exe is the default one on Windows. See
Windows and cross-platform scripts.
- Not an Ansible replacement. No inventory graph, no declarative convergence. Xec is imperative TypeScript for the automation you would otherwise write in bash — with types, tests and one API instead of four.
- Not an SDK wrapper. Adapters speak the native tools (ssh2 protocol, docker CLI, kubectl), so behaviour matches what you would get by hand — including exit codes.
corepack enable && pnpm install && pnpm build
pnpm test # unit tests
pnpm --filter @xec-sh/core docker:start # SSH test fixtures
pnpm lint && pnpm typecheck # both are kept at zeroLicense: MIT