Skip to content
GitHubRSS

One command per task; the shell is the API

exec.command is a string. It runs under sh -c with the project’s and the workspace root’s node_modules/.bin on PATH, in the project’s directory, with the environment you declared. That is the entire execution model, and it is a constraint chosen on purpose.

JavaScript-function tasks. A task that is a function in the config file is convenient right up to the moment you want to run it somewhere else. It cannot be shipped to a worker, cannot be sandboxed at the OS level, cannot be replayed from a log, and its inputs are whatever the closure captured. vx has no run: async () => …, and will not.

Executors. Nx wraps tools behind plugins with options objects, so @nx/js:tsc with { main, tsConfig } is a layer between you and the tsc documentation. When the tool adds a flag, the executor has to learn it. When the executor has a bug, the tool did not. vx has no executor plugins; a plugin changes where a command runs, never what it is.

Step lists. A task that is [build, then copy, then compress] is three tasks pretending to be one, with one cache key for three behaviours. Chain with && if the steps truly are one unit, or split them into tasks wired by dependsOn so each caches independently.

Once a task is a command with declared inputs and outputs, every capability in vx becomes a transformation of the same triple:

  • Remote execution ships it as one REAPI action: the command string, the declared input tree, the declared output paths. Nothing about the task has to be serialisable beyond what it already is.
  • The sandbox wraps the command in bwrap or seatbelt with the declared paths. There is exactly one process to confine.
  • Replay stores the captured stdout in the cache row and prints it byte-identical on a hit, NUL bytes, carriage-return progress rewrites and raw ANSI included. A hit looks like the run.
  • Migration from Turborepo or Nx is mostly a rendering problem, because both of them ultimately run a command too; vx just writes it where you can read it.
  • vx show, --dry, MCP’s listTasks all print the same thing a human would type.

A command’s environment is part of what it does, so it is not inherited wholesale. Each task gets an isolated environment built from exec.env: values you set (part of the config, so in the key) and variables you passThrough from the parent (not in the key). cache.inputs.env puts a variable in the key but does not pass it to the task, so a variable that changes the output and must reach the task goes in both. PATH is prepended with the project’s node_modules/.bin, then the workspace root’s, so tsc resolves without npx. A variable that changes the output and is missing from cache.inputs.env is the most common under-declaration, and it shows up as a stale hit: the key did not change, so vx replays the old output.

Two variables are always set: the workspace vx is running and the task id, so a command that runs vx itself against the same workspace is refused instead of recursing into the same cache.

The practical consequence is that vx never needs a plugin for a tool. There is no @vzn/vx-vite, no @vzn/vx-jest, and there is not going to be one, because vite build and jest are already the API. The repository briefly shipped a package that inferred tasks from tool configs and retired it: technology-specific knowledge is the community’s to write as presets, in TypeScript, on top of a runner that only knows what a command is.

Reference: Running tasks and Environment variables.