An option works or it fails loudly
.cd() on a container changes the directory in the container. .env() on a pod exports in the pod, and never leaks into your own process. Nothing is accepted and quietly dropped.
A typed execution layer for TypeScript infrastructure. Run commands on your laptop, an SSH fleet, Docker containers and Kubernetes pods through one API — with the same result type, the same errors and the same streaming everywhere.
npm i @xec-sh/core
import { $ } from '@xec-sh/core';
// the same command, four environments
await $`npm run build`;
await $.ssh('deploy@web-1')`systemctl restart api`;
await $.docker('api')`python migrate.py`;
await $.k8s('prod/api-pod')`./healthcheck.sh`;
Running a command somewhere other than your own machine means assembling four libraries with four APIs, four error shapes and four streaming models — then keeping them in step.
execalocal processesssh2remote hostsdockerodecontainers@kubernetes/client-nodepodsFour result types. Four ways a failure surfaces. Moving a service from a container to a host means rewriting the code that talks to it.
const result = await $.ssh(host)`systemctl status api`;
result.ok // exit 0 and not signalled
result.stdout // string
result.stdall // both streams, in arrival order
result.duration // ms
// same shape for local, docker and k8sOne result type, one error hierarchy, one streaming model. The target changes; the code does not.
Each of these is enforced by a test in this repository. They are written as promises about what will not happen to you, because that is what you need to know before running something against a production host.
.cd() on a container changes the directory in the container. .env() on a pod exports in the pod, and never leaks into your own process. Nothing is accepted and quietly dropped.
Output past maxBuffer kills the producer and fails with the truncated head kept — never an empty result with exit code 0. A process killed by a signal is never ok, and reports 128 + signum.
Interpolated values are quoted for the shell that will actually parse them, so a value can never change the structure of a command. $.raw exists for when you mean it.
Tokens, API keys, URL credentials and PEM blocks are redacted in output, events, error messages and the verbose echo — with one rule set, including across stream chunk boundaries.
sh -c "node server.js" is a process tree. Kill, abort, timeout and buffer overflow all signal the whole group, so nothing is orphaned holding a port.
Cache keys carry the host, container, pod, namespace and cluster. One machine’s answer is never served for another, so a health check cannot report on the wrong box.
npm i @xec-sh/coreconst staging = $.ssh('deploy@staging')
.cd('/srv/app')
.env({ NODE_ENV: 'staging' })
.timeout('60s')
.retry({ maxRetries: 3 });
await staging`pnpm migrate`;
for await (const line of staging`tail -f app.log`) {
if (line.includes('ERROR')) alert(line);
}Every environment takes the same chain. Output streams as it arrives, so a follow works the way you expect.
npm i -g @xec-sh/clixec on deploy@prod-1 'systemctl restart api'
xec in postgres-main 'pg_dump mydb'
xec in production/api-7f9d 'cat app.log'
xec run deploy.ts # a script, with $ in scope
xec forward hosts.prod 8080:80Targets, defaults and tasks live in .xec/config.yaml. Scripts get the same API the library exposes — nothing is CLI-only.
Knowing where a tool stops is worth as much as knowing what it does.