Skip to main content

One $ for every environment

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

deploy.ts
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`;
1runtime dependencyssh2 — loaded only when an SSH target is used
4environments, one APIlocal, SSH, Docker, Kubernetes
4,500+testsacross the engine, CLI, loader and UI kit
~150msCLI startupagainst a ~20ms floor for an empty Node process

The seam this closes

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.

Assembled by hand
  • execalocal processes
  • ssh2remote hosts
  • dockerodecontainers
  • @kubernetes/client-nodepods

Four result types. Four ways a failure surfaces. Moving a service from a container to a host means rewriting the code that talks to it.

With xec
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 k8s

One result type, one error hierarchy, one streaming model. The target changes; the code does not.

The contract

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.

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.

No silent data loss

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.

Interpolation is safe by default

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.

Secrets stay out of logs

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.

Killing a command kills its tree

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.

A cached result belongs to its target

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.

Two ways in

As a library
npm i @xec-sh/core
const 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.

As a CLI
npm i -g @xec-sh/cli
xec 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:80

Targets, defaults and tasks live in .xec/config.yaml. Scripts get the same API the library exposes — nothing is CLI-only.

What Xec is not

Knowing where a tool stops is worth as much as knowing what it does.

  • 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 — the ssh2 protocol, the docker and kubectl CLIs — so behaviour matches what you would get by hand, exit codes included.
  • Not a dependency tree. The execution core declares one runtime dependency, ssh2, and loads it only when an SSH target is used. Running a command locally loads no third-party code at all.