Skip to content
Dev Tools Article

Xec Gives zx's $ a Passport to SSH and Kubernetes

A one-maintainer TypeScript project finally builds the Fabric-style remote execution layer JavaScript scripting never had.

Mariana Souza
Mariana Souza
Senior Editor · Aug 26, 2026 · 5 min read
Xec Gives zx's $ a Passport to SSH and Kubernetes

Every team that adopted zx hit the same wall eventually. The $ template tag makes local scripting pleasant, but the moment a script needs to restart a service on a box, run a migration inside a container, or poke a pod, you're back to $\ssh deploy@web-1 "systemctl restart api"`` — string-building the very thing zx was supposed to abstract away. Xec is a small project that takes that wall as its whole reason to exist.

What it actually does

Xec ships @xec-sh/core, a $ that looks like zx's but takes a target. The four calls below return the same result shape — ok, stdout, stdall, duration, exit code — no matter where the command ran:

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

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`;

The interesting design decision is what's underneath. Xec isn't an SDK wrapper. SSH goes through the ssh2 protocol library — the package's only runtime dependency, and it's lazy-loaded, so a purely local script pulls in no third-party code. Docker and Kubernetes are handled by shelling out to the docker and kubectl binaries. The Kubernetes docs are explicit: pod execution "goes through kubectl exec", using whatever kubeconfig context you already have. Pod targets accept label selectors (pod: '-l app=web,env=production') and resolve to the first match.

Around the core there's a monorepo: @xec-sh/cli (with xec on user@host "uptime" and xec in my-container "npm test" one-liners plus a REPL), @xec-sh/ops for deploy strategies and health checks, a loader for TypeScript scripts, and a terminal-UI kit. A .xec/config.yaml declares named targets and tasks, so hosts.prod becomes a thing you reference instead of a connection string you repeat. The SSH adapter does connection pooling, SFTP upload/download, local and reverse port forwards, and a SOCKS proxy via dynamicForward().

The idea is right; the prior art says so

This isn't a new problem, it's a new language for an old one. Python solved it years ago with Fabric — a run()/sudo() API that treats remote hosts as first-class — and later pyinfra, which pushed the same idea toward declarative operations while keeping the "it's just Python" ergonomics. The JavaScript world never got its Fabric. zx (45.7k GitHub stars, Apache-2.0, Google-backed) deliberately stops at child_process. So do execa, dax, and Bun's built-in $ shell. Xec's README names all four and says, fairly, that it's "the same $ across all four" targets.

The consequence of that gap is visible in real repos: deploy scripts that are 60% TypeScript and 40% quoted bash embedded in ssh and kubectl exec invocations, with escaping bugs that only surface when a filename has a space. Xec's safe interpolation — template-literal interpolation is shell-quoted automatically, the same trick zx uses — extends to the remote side, which is exactly where hand-rolled scripts get it wrong.

Shelling out to docker and kubectl is the correct call, too, even though it sounds less impressive than "native API client." It means exit codes, auth, contexts, and plugin behavior match what you'd get by hand. The downside is symmetric: Xec doesn't reduce your prerequisites. A CI runner still needs kubectl on PATH and a kubeconfig, same as the bash script it replaced.

Where it sits against Ansible

The sharper comparison isn't zx, it's Ansible. Xec's own framing — "imperative TypeScript for the automation you would otherwise write in bash" — is a bet that a lot of teams don't want declarative YAML for the glue layer. They want a typed function that runs a migration, waits on a health check, and rolls back if it fails, and they want to unit-test it with Vitest like any other module.

I think that bet is sound for the middle of the stack: release scripts, on-call runbooks, fleet-wide one-offs, the stuff that today lives in a scripts/ directory nobody's proud of. It's a bad bet for convergent infrastructure state. There's no idempotence model here; $.ssh(host)\apt install nginx`` runs the command, full stop. That's a feature at the runbook layer and a liability if you try to grow it into config management.

Should you use it?

Here's the honest state of the project as of late August 2026: @xec-sh/core is at 0.11.1, MIT-licensed, first published to npm on 29 July 2025, and the GitHub repo has 9 stars and zero forks. It's the work of a single author (publishing as LuxQuant), and the only writeup outside the project's own site is that author's dev.to post. I couldn't find an independent review, a production case study, or a discussion thread with any real signal. The release log shows 0.10.0, 0.10.1, 0.11.0 and 0.11.1 all landing within a single week in early August, with a breaking change in 0.10 that relocated service presets and restructured dependencies. That's a project in motion, not a stable one.

The README asks for Node 22.18+ (the npm manifest is looser, at >=20), and the project claims byte-identical results on Bun and Deno.

So the adoption answer splits cleanly:

  • Adopt @xec-sh/core opportunistically. If you have a zx or execa script that already string-builds ssh or kubectl exec commands, swapping to $.ssh() and $.k8s() is a contained change with a one-dependency footprint and an obvious rollback. Pin the minor version.
  • Don't build your deploy pipeline on @xec-sh/ops yet. Deploy strategies and rollback logic are exactly the code you want boring and battle-tested, and the ops package has had neither time nor users. Keep the strategy in your own code; let Xec be the execution primitive under it.
  • Watch for the bus factor. A one-maintainer project with a roadmap that already mentions AI command translation and cloud-provider adapters is spreading thin. The core is small enough to vendor if it goes quiet, which is a genuine mitigation — but you should know that going in.

The abstraction Xec offers is the one the JavaScript scripting ecosystem has been missing since zx made local shelling pleasant. Whether this particular implementation is the one that fills the gap depends less on the design, which is good, than on whether anyone besides its author shows up. If you're already writing this glue in TypeScript, it's worth an afternoon. Just treat the 0.x on the version tag as a promise, not a formality.

Sources & further reading

  1. Xec - Universal Command Execution System — xec.sh
  2. xec-sh/xec: A typed execution layer for TypeScript infrastructure — github.com
  3. @xec-sh/core on npm — npmjs.com
  4. Pod Execution - Xec docs — xec.sh
  5. SSH Adapter - Xec docs — xec.sh
  6. Xec - Write Once, Execute Anywhere: Universal Shell Commands in TypeScript — dev.to
  7. google/zx: A tool for writing better scripts — github.com
Mariana Souza
Written by
Mariana Souza · Senior Editor

Mariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading