Skip to content
vxvx
GitHubBlueskydev.toRSS

@vzn/vx-migrate

Everything for adopting @vzn/vx from Turborepo, Nx or Vite Task, in one package with zero dependencies:

  • turbo() — a temporary start for a Turbo repository: the plugin fills vx’s project stage from turbo.json and each package’s package.json scripts until the migrator writes native config.
  • nx() — the same temporary start for an Nx repository, filled from Nx’s resolved project graph. Executor targets (@nx/js:tsc, @nx/vite:build, your own) run as themselves through nx-exec, one executor per process.
  • bunx @vzn/vx-migrate — write one vx.config.ts per workspace package from your turbo.json, an exported Nx project graph, or vite-plus’s run.tasks (Vite Task, vp run), plus the workspace file every run needs. Runs without a workspace file, so it is the first command, not the second.
  • turboCache() and nxCache() — keep the remote cache you have: any server speaking Turbo’s /v8/artifacts API (Vercel’s hosted cache included) or Nx’s self-hosted /v1/cache spec.
Terminal window
npm install -D @vzn/vx @vzn/vx-migrate # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d

In a Turbo or Nx repo, npx vx init writes the vx.workspace.ts that declares turbo() or nx() and prints the install-and-run line. Add turboCache() or nxCache() to keep your remote cache:

// vx.workspace.ts — a Turbo repo mid-migration, with its remote cache
import { defineWorkspace } from '@vzn/vx/config'
import { turbo, turboCache } from '@vzn/vx-migrate'
export default defineWorkspace({ plugins: [turboCache(), turbo()] })

The package exports turbo, nx, turboCache, nxCache and their options types (TurboPluginOptions, NxPluginOptions, TurboCacheOptions, NxCacheOptions); the mappers and cache clients are internal.

Until bunx @vzn/vx-migrate writes native config, vx run build --all maps every package’s build script the way turbo run build would: dependsOn edges (^build, same-package deps, pkg#task), inputs / outputs as the cache block, env / passThroughEnv, cache: false, persistent. Turbo’s global fields (globalDependencies, globalEnv, globalPassThroughEnv, Turbo 1’s globalDotEnv) are inlined into every task; a * env name (NEXT_PUBLIC_*) expands over the run’s environment, as Turbo matches it; * is Turbo’s only wildcard (\*, a leading \!, ? and [ are literal), and a literal vx cannot key (unkey’s NEXT_PUBLIC_\*) is left out, reported once only when the run’s environment sets it; per-package turbo.json overlays apply. A turbo.jsonc (Turbo 2.5+) is read wherever a turbo.json would be, at the root and in a package; a workspace root with neither is refused, naming the fix. Turbo 2’s framework inference (a package on next has NEXT_PUBLIC_* and NEXT_DEPLOYMENT_ID hashed and passed; Turbo’s whole table, expo, nuxt, remix, @sveltejs/kit and the rest, each package taking the first framework it matches, as Turbo does, and leaving out names under the platform’s TURBO_CI_VENDOR_ENV_KEY) applies too: the prefix expands over the run’s environment into that package’s tasks, and a task’s ! entry takes names back (bunx @vzn/vx-migrate, which writes files for another environment, names each framework and its packages in a note instead). What runs is what a migration would have written, minus the file: the key a task derives here equals the key the written config would derive.

OptionMeaning
rootDirectory holding turbo.json. Defaults to the workspace root.

vx lock freezes the evaluation of written vx.config.* files only. A package that has none gets its tasks from turbo.json on every load — under --frozen too — so the lock records nothing for it and vx lock --check does not audit it; turbo.json is its source of truth, committed like one.

  • A task the package’s own vx.config already declares is left alone — the plugin fills, it never overwrites. Migrate a package by writing its config; the rest of the repo keeps running from turbo.json.
  • The mapping’s gaps are the migration’s gaps, reported as warnings on every run instead of TODO(vx-migrate) comments: $TURBO_ROOT$ tasks, an env name vx cannot key that the run’s environment sets (FOO_?, which Turbo reads literally), unknown turbo keys. A persistent task’s readiness note (readyWhen) is reported only when something depends on it — with no dependent there is nothing to gate (refine printed it for 375 dev targets a run). bunx @vzn/vx-migrate --dry lists the same set once. Each gap is one line per run for every task that carries it, not one per task (n8n marks dev and watch persistent in most of its 84 packages; astro negates vendor/** in the outputs of 57 tasks). A per-package { "extends": false } with nothing else is Turbo’s opt-out and maps to no task; with keys, the task runs on those keys alone. A glob that climbs out of the package (../../packages/app-store/*.generated.ts) is re-anchored on the workspace root as a workspaceFiles glob. A negated output under a literal-rooted one (dist/** minus !dist/**/*.map) takes its paths back (see outputs below); one against a wildcard-rooted output (medusa’s */** minus !src/**) makes the task uncached, because the clean before exec would otherwise delete the sources — declare the exact outputs in a vx.config to cache it.
  • The cost is the stage’s: on a 1,000-package workspace with no vx.config files the mapping loads in about 42 ms where 1,000 evaluated configs load in 22.
  • The mapping is kept in <cache dir>/vx-migrate-turbo-mapping.json, keyed on every turbo.json / turbo.jsonc, every package manifest, the mapper’s own code and the environment variable names a wildcard can match (one in a Turbo config or Turbo’s framework table) or core cannot key, so an unrelated variable keeps it; a run with none changed reads it instead of mapping (astro, 562 packages: ~40 ms of mapping against ~12 ms of key reads).
  • The mapping is read once per run, never once per process: under vx watch, a package.json script edit or a per-package turbo.json overlay edit is the next cycle’s tasks. The root turbo.json is no task’s input and lives in no project dir, so an edit to it is not a cycle — restart the watch.

The plugin and the CLI read a repo the same way: the mapper the plugin runs live is what bunx @vzn/vx-migrate writes vx.config.ts per package from, splicing Turbo’s global fields in as imports of a generated vx-preset.ts (its exports alone, no comments), where the plugin inlines the values; a global input another task writes to is taken back after the spread ([...globalInputs, '!packages/plugin/dist/**']), as the plugin takes it back from the inlined values. splice is the seam between the two consumers; uses names which globals a task drew on. Before 2026-09-10 the mapper lived in @vzn/vx itself; until 2026-09-11 the plugin was its own package, @vzn/vx-turbo.

Rules:

  • A task exists for a package only when the package declares the script — Turbo’s own rule. An absent script is silent; a script key whose value cannot be a command (a number, null, an empty string, an array) produces a task: null entry whose todo says why, rather than a config that fails to load.

  • Definition order: root pkg#name if there is one, else root name (the first replaces the second whole, as Turbo looks it up), then each package config its own turbo.json extends ("extends": ["//", "shared"], read by Turbo 2.11, refused by 2.5; nearest last, a parent missing or a cycle refused as Turbo refuses it) and its own, each winning field by field, except an overlay array holding $TURBO_EXTENDS$ (Turbo 2.5+), which is the inherited list plus the overlay’s other entries (inputs: ["$TURBO_EXTENDS$", "config.json"]).

  • The command is the script body with its pre<name> / post<name> hooks folded in, in that order, through core’s foldScriptHooks: each part in its own subshell, the chain stopping at the first that fails, forwarded -- args reaching the body alone (npm and pnpm run them around <name> without being asked; novu’s prebuild copies the CSS its build inlines), one process less per task than pnpm run <name> — except a body that calls yarn’s run builtin (run -T rollup -c, run clean && run build; yarn ≥ 2 runs scripts in its own shell), which is run: command not found in sh: it runs as yarn run <name>, as Turbo and Nx run it, and yarn ≥ 2 runs no hooks, so none are folded there. A Yarn Plug’n’Play workspace (.yarnrc.yml with no nodeLinker, or pnp) has no node_modules: a dependency resolves only through .pnp.cjs and a bin only through yarn’s shims, so there every script runs as yarn run <name>, and a nodeLinker change maps afresh. Same rules for nx:run-script below (strapi and novu, 2026-09-11).

  • Turbo 2.11’s task command (futureFlags.experimentalTaskCommand) wins over the script, as Turbo holds it: an argv is the command — each word quoted, run from the package dir, no pre/post hooks — and gives the package the task even with no script (turborepo’s @turbo/types#build, docs#schema); null or [] is Turbo’s no-op node, no task even where the script exists, its edges passed through; a per-toolchain map applies its javascript entry (or typescript, Turbo’s alias), and without one the script runs. A shape Turbo would refuse keeps the script, with a todo.

  • dependsOn: ^x passes through when some package runs x or turbo.json defines it, and is dropped otherwise (core refuses a ^x no project declares as a typo); pkg#task is kept only when pkg emits task (else a todo: edge dropped); a same-package task Turbo defines but the package has no script for is Turbo’s no-op node, so its own edges pass through in its place (test → codegen → ^build with no codegen script is test → ^build); another package’s ^x or pkg#x reaches such a node through a group task when its edges name a task of its own package or another (with-tailwind’s ui has no build script; its build builds build:styles and build:components, and web#build waits on them), and so does one whose ^ edge names another task (rallly’s script-less build holds ^db:generate), while one whose only edge is ^ to its own name needs none, since core’s ^x walks past a package without x; a no-op node nothing else reaches is a group too in a package nothing but the root depends on, since no dependant’s ^ edge runs its edges there (vercel/ai’s turbo run type-check builds each example through its script-less type-check: [build], and kitchen-sink’s turbo run test builds @repo/ui for admin, which has no test script); elsewhere it is none, its edges run by its dependants’; a name Turbo does not define has no edge; $TURBO_ROOT$ deps are a todo. A task turbo.json defines and no package has a script for (documenso’s lint, shadcn-ui’s check) is a group task in each package that defines it, its edges kept (a pkg#task edge only in pkg), so vx run lint runs Turbo’s no-op nodes and exits 0 as turbo run lint does. Root //#tasks run in the workspace root, as under Turbo: turbo() names the root a project through core’s discover stage when turbo.json declares one (no vx.config needed; a root no member glob lists), and //#x in dependsOn is an edge to it, named by the root package.json name; a root task’s globs are the workspace’s (workspaceFiles), since Turbo hashes a root task over the whole repo; with no root project (a root package.json with no name, or one a package holds) they are a note and such an edge a todo. Turbo runs no plain task in the root package of a monorepo, and neither does turbo(); in a single-package repo (no workspaces, Turbo’s non-monorepo example) turbo.json’s plain tasks run on the root package, as under Turbo.

  • A transit node (Turbo’s documented pattern, its with-vitest example: transit: { dependsOn: ["^transit"] } with no script anywhere, and test: { dependsOn: ["transit"] }) is a key-only task in each package that defines it: a keyed group (no command, nothing spawns) keyed on its inputs, with its ^transit edge, so a dependant’s test re-keys on its dependencies’ sources as Turbo re-hashes it. A ^ task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a ^ task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants. A build whose only edge is ^build is not written: core gives every project with no build that node (solid’s three script-less packages each got a build of their own). A dependency without the script whose own edges lack ^x gets it too, so a ^x stops there as Turbo’s does (opencode’s build: { dependsOn: [] }: core walked past to the builds below, which Turbo’s ^build never reaches).

  • with (tasks Turbo runs alongside, web#dev with api#dev): an edge to each sidecar that is persistent, so vx starts the task once the sidecar has spawned and runs the sidecar only when the task runs; a sidecar that ends would be waited for, so it is a todo instead, and a pair that names each other keeps one edge (two would be a cycle). A task with no script whose with names persistent sidecars (Turbo’s with-tailwind example: ui has no dev script, its dev starts dev:styles and dev:components) is a group task that depends on them.

  • inputs: a structured entry (Turbo 2.11) is its globs, plus **/* with withDefaults, for startup and jit alike; dependencyOutputs adds none where the producer is cached (vx folds each dependency’s key); from a cache: false producer (vercel/vercel’s //#generate:cache-keys, which records the host) the named files, or its outputs, are a cache.inputs.workspaceRuntime probe, and cache.inputs.tasks leaves the producer out, so core answers the probe after it ran. Then: absent or [] → **/* (Turbo’s default); $TURBO_DEFAULT$ → **/*; exclusions alone narrow **/*; $TURBO_ROOT$/<path> → cache.inputs.workspaceFiles (negation kept); any other $TURBO_ROOT$ use is a todo. globalDependencies and Turbo 1’s globalDotEnv land in workspaceFiles too, and a task’s Turbo 1 dotEnv in its files. Turbo 1’s $NAME entries, in globalDependencies or a task’s dependsOn, are env vars: they join cache.inputs.env and exec.env.passThrough. A package nested in the task’s package (cal.com’s apps inside @calcom/app-store) is hashed by Turbo with its parent, and core’s globs stop at a project: each glob that reaches one is also listed in cache.inputs.workspaceFiles. Turbo’s glob grammar is translated as Nx’s is (*.[jt]s is *.{[jt],j,t}s); an input with no safe form widens to **/* with a todo. A .env-shaped input (.env*, .env.local, a task’s Turbo 1 dotEnv, create-turbo’s globalDependencies: ["**/.env.*local"]) is gitignored as a rule, so it is keyed by a probe that hashes the .env files it can name (cache.inputs.runtime) or, for a root entry, the files its root-relative globs name, hidden ones too, as Turbo’s * matches them (workspaceRuntime; **/<name> is a find -name that skips node_modules, and another ** walks the workspace), rather than as a file glob git never reports. A package whose .env globs all sit at its root gets a one-shell probe of that directory; one below the root walks the package.

  • outputs: $TURBO_ROOT$/<path> → cache.outputs.workspaceFiles; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next’s .next/** minus !.next/cache/** keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo’s grammar (dist/**/*.[cm]js); the first stays a literal (a route directory). An output whose first segment is a wildcard (**/*.d.ts, */**) runs the task uncached with a todo: Turbo never cleans an output, vx cleans it before every run, and such a glob reaches the sources. One segment with a literal extension the package tracks no file of (*.xml, *.tsbuildinfo) reaches only top-level artifacts and stays cached, and so does **/<dir>/** when the package tracks nothing under a directory of that name (vercel/ai’s **/dist/**; node_modules is never an output), and so does a first segment no tracked top-level entry matches (tldraw’s dist-*/**). A wildcard first segment with a literal rest (clerk’s */package.json, the subpath stubs it commits) reaches only files of that name: each committed one, up to 16, is taken back with ! and the task stays cached; more, and it runs uncached. An output that covers the package’s own package.json (trpc’s client build lists it: the build rewrites exports) runs the task uncached with a todo: vx would delete the manifest before every run, and core refuses that config. nx() takes the manifest back with !package.json instead and keeps the cache (owner, 2026-10-03). So does one that covers the package’s vx.config; turbo() and nx() check the config the package has, and vx-migrate the one it writes, so sanity’s *.js shims stay cached beside none. nx() keeps a wildcard-first output cached instead: each committed file it reaches is taken back with ! (past sixteen, uncached with a todo), and the clean removes the rest, what the task writes (owner, 2026-10-03). A committed file under an output (typescript-eslint’s data/sponsors.json in a cached data) is taken back with ! — Turbo and Nx never clean an output, vx does — so it survives the clean and the task keeps its cache; past sixteen such files the task runs uncached with a todo. turbo() and nx() both do this, and so do the configs vx-migrate writes from turbo.json or an Nx graph (each take-back is a todo there, as it names files a later commit may move).

  • env / passThroughEnv: explicit names go to cache.inputs.env (env only) and exec.env.passThrough (both, plus both globals); a wildcard expands over the run’s environment under turbo(), which maps where the tasks run (a new variable name such a wildcard matches maps afresh); the CLI, which writes for another environment, lists the names the task’s package and its workspace dependencies spell (a root task: the whole repo; clerk’s E2E_*), noted once per entry, and one nothing spells is a todo; * is the only wildcard, as in Turbo, so \*, a leading \!, ? and [ are literal: unkey’s NEXT_PUBLIC_\* names one variable, which vx cannot key, so it is dropped and reported once for the workspace with the count of tasks naming it (openstatus’s root build env \* had written one todo into each of 45 configs; turbo(): only when the run’s environment sets it, as Turbo hashes nothing otherwise); and a ! entry (openstatus’ !NEXT_PUBLIC_VERCEL_URL) removes the names it matches from its list, with no todo, since vx matches no name it is not given. A name both a global list and the task’s own list carry is listed once. A name that is no shell variable name (p-q) is keyed but left out of exec.env.passThrough, as sh drops it before the task runs.

  • Framework inference (Turbo hashes and passes Next’s NEXT_PUBLIC_*, Vite’s VITE_*, … for a package that depends on the framework): turbo() expands the prefix over the run’s environment; the CLI writes the names the tracked source and .env.example of the package and its workspace dependencies spell (the bare prefix, as a startsWith test spells it, is none) (Next bundles theirs: cal.com’s web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.

  • Two tasks of one package on one output path (strapi’s build, build:code and build:types, all on dist/**): vx cleans a task’s outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx’s default rules.exclusiveOutputs an edge between the two changes nothing (twenty’s build:individual writing into build’s dist). The mapping resolves it before the file is written — the task with a ^ edge keeps its cache (the first declared when none has one); the rest run uncached with a todo naming the keeper and the fix, their own output path. Same rule for Nx targets.

  • A task whose inputs read another task’s outputs (a test keyed on **/* beside a build writing dist/**): vx’s default rules.upfrontKeys refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader’s inputs with ! (['**/*', '!dist/**']), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build’s outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the ! would hide it from runs uncached with a todo. An output that names one committed file (eslint’s eslint-suppressions.json in a lint task’s outputs) is dropped, and so is the ! each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.

  • Two packages’ tasks on one workspace output (cal.com’s shared post-install writes ../../node_modules/@prisma/client/** from every package with the script): core refuses two cached tasks on one path with no edge between them, so turbo() (and nx()) keeps the first (in package and task order) cached and runs the rest uncached, each with a todo naming the keeper. A package’s own outputs count at their workspace path: typescript-eslint’s root project caches dist and each package’s typecheck dist/packages/<name>, one path twice. Edges across packages are not read, so an ordered pair loses its cache too.

  • cache: false or persistent: true → no cache block; a persistent task gets exec.persistent: {} and, when some task depends on it, the consumer’s persistentTodo.

  • A task’s description (Turbo 2.11.5’s schema) is the vx task’s description.

  • outputLogs: "new-only" maps to nothing: frames for the tasks that ran and a one-liner per cache hit is vx’s default flow already. The other values are per-run in vx, so they are a todo naming the flag (vx run … --output-logs hash-only).

  • interruptible maps to nothing: vx watch keeps a persistent task up across cycles and restarts it only when its config changes or it dies.

  • A task’s tags (Turbo main, after 2.11.5) map to nothing: Turbo keeps them out of the hash and the run.

  • envMode: "loose" (top-level or in global, or TURBO_ENV_MODE=loose, which wins) is a note: Turbo hands every task the whole environment, vx only the declared names.

  • The workspace keys vx has a home for, top level or in global, fill what vx.workspace.ts leaves unset: concurrency ("10", "50%" of the cores) → concurrency; cacheMaxSize / cacheMaxAge ("0" is off; weeks become days, a bare number days; a size in Turbo’s grammar, 7.5GB or bare bytes, restated whole: 7680MB) → cacheRetention.maxSize / .olderThan. TURBO_CONCURRENCY, TURBO_CACHE_MAX_SIZE and TURBO_CACHE_MAX_AGE win over turbo.json, as under Turbo; a set one decides, its 0 included.

  • TURBO_SCM_BASE, the base turbo run --affected compares with, is affectedBase when vx.workspace.ts sets none: a bare vx run build --affected compares with the same ref. With it unset on GitHub Actions (GITHUB_ACTIONS), Turbo’s own base applies: a pull request’s GITHUB_BASE_REF, else the push event’s before from GITHUB_EVENT_PATH.

  • Turbo hashes each package’s microfrontends.json (or .jsonc) into every task, or the one file VC_MICROFRONTENDS_CONFIG_FILE_NAME names; so does turbo().

  • Turbo 2.11’s global block (futureFlags.globalConfiguration) is read as the globalDependencies, globalEnv and globalPassThroughEnv it replaces.

  • An unknown Turbo key is a todo naming it; extends is accepted and ignored (the overlay order above is what it means).

// vx.workspace.ts — an Nx repo, mid-migration
import { defineWorkspace } from '@vzn/vx/config'
import { nx } from '@vzn/vx-migrate'
export default defineWorkspace({ plugins: [nx()] })

Until bunx @vzn/vx-migrate writes native config, vx run build --all maps every project’s build target the way nx run-many -t build would. The plugin reads Nx’s resolved project graph — where Nx has already applied targetDefaults, expanded namedInputs, inferred targets through its plugins and interpolated {projectRoot} and friends — so nothing is re-derived here. Per target: nx:run-commands and a plain command run as one shell line that does what Nx’s run-commands does (below); nx:run-script is the package script, with the $npm_package_name, $npm_package_version and $npm_lifecycle_event it reads defined as <pm> run sets them (a migration reads the name and version from the manifest it imports, so a bump reaches them; any other $npm_* is a todo); nx:noop is a group task, and so is a target with neither an executor nor a command that has dependencies, as Nx normalizes it (one with none is dropped, as Nx drops it); every other executor runs through nx-exec with the executor and its options on the command line. A target Nx caches (cache: true, or the legacy cacheableOperations list in nx.json) gets its inputs / outputs as the cache block, with no inputs meaning Nx’s default and ^default, named inputs resolved per project (nx.json’s under the project’s own; one neither defines, such as an extends preset not installed, keys the whole project with a todo, never an empty list that keys the task on its config alone), {workspaceRoot} / {projectRoot} / {projectName} interpolated anywhere in a path as Nx does ({workspaceRoot}/coverage/{projectRoot}), Nx’s glob grammar translated (*.[jt]s is *.{j,t}s; the default production negation ?(*.)+(spec|test).[jt]s?(x) becomes brace sets, a narrowing only a negation may take; what has no safe form is a todo), a project fileset of negations alone (nx-recipes’ noMarkdown) starting from every project file, as Nx reads it, and an output outside the project dir (dist/<project> at the workspace root, Nx’s default layout) a workspaceFiles output, an output outside the workspace (an old generator’s reportsDirectory: "../../coverage/<lib>") a todo and dropped, as vx caches only inside it, an output whose first segment is a wildcard ({projectRoot}/**/*.d.ts) cached, with each committed file it reaches taken back by ! and the rest cleaned before the run (typescript-eslint’s **/*.shot, 3,656 committed snapshots, is past sixteen and runs uncached with a todo), and an output that covers the project’s own package.json or vx.config cached with the file taken back by !; dependsOn becomes the edges (^build — dropped when no project has the target, as Nx gives it no edges and core refuses a ^name nothing declares — build, project:target → project#target, everything past project: one target name, as Nx joins it (ui:build:esm is ui’s build:esm; ui:build:ci names target build:ci, never build’s ci configuration, and is no edge where ui lacks it), this project’s own target winning over a project name, { target, projects } → each matched project’s task, projects read as Nx’s findMatchingProjects reads it: a name or a list of names, globs over names and over project directories (libs/*), name: / tag: / directory: labels, a bare word as a word in a name, and ! exclusions; an edge to a target its project lacks is dropped without a word, as Nx drops it — targetDefaults that give every typecheck a codegen one project has; ^name is every dependency’s name whatever it holds, ^rsbuild:typecheck included; a target glob — test:e2e--*, ^build-*, ui:build-{esm,cjs}, a { target } object’s — expands over every target name in the workspace, as Nx 19.5+ does, before those rules; a same-project glob keeps its own project’s matches), { env } inputs pass through (one that is no shell variable name is keyed only), { runtime } inputs run at the workspace root as Nx runs them (cache.inputs.workspaceRuntime), a { fileset, includeIgnored: true } literal (Nx 23 hashes it from disk, gitignored or missing) is read by such a probe, since vx’s globs see only what git lists, and a glob of one is a todo, a target that says continuous — or, in a graph from an Nx older than that field, runs a server executor (@nx/vite:dev-server, @nx/next:server, …) — is a persistent task and never cached, ready on spawn as Nx has it (Nx starts the dependents once it has started; a run-commands readyWhen gates them, below). The target’s name never decides: a cached dev caches like any other target (nx#32610). parallelism: false (Nx runs the target alone) has no vx form and is a todo naming --concurrency 1; syncGenerators (Nx runs them first) is one workspace note per generator list, counting its tasks and naming nx sync; nx.json’s sync.globalGenerators is one note per run naming nx sync. A target with configurations is one task per configuration (one whose name another target holds, vite’s build beside an inferred vite:build, is not written, with a todo, as Nx resolves a:vite:build to the target): build carries the default configuration’s options, build:ci the ci one, and an edge of build:ci (own, named or ^) reaches each target’s ci task where it declares one, else its default, as Nx’s resolveConfiguration does; a ^build there becomes one edge per dependency Nx links. A ^name input (^production, { input, dependencies: true }, the pre-17 { input, projects: "dependencies" }; projects: "self" is the own input, as Nx 23 still reads both) is what Nx hashes: each dependency’s name, over the project graph, whether or not a task edge exists. A { input, projects } list is read as dependsOn’s projects is. Each project some reader reaches gets an nx-input:<name> task — a keyed group (no command, nothing spawns, not counted in the run summary) keyed on its own name input, depending on its Nx dependencies’ twins (a project nothing reaches gets none) — and a task that reads ^name depends on its direct dependencies’ twins, so the whole closure folds into its key with each project hashed once. A dependency FILESET (^{projectRoot}/tsconfig.lib.json, { fileset, dependencies: true }, which Nx’s own inferred typecheck writes) folds the same way: its twin, nx-input:fileset-<hash> (a glob’s * is no task name; the task’s description names the fileset), keys on that fileset in each project. A twin lists its project’s package.json and project.json first (whichever exists; Nx keys every task on its project’s manifest and configuration): a fileset that matches nothing (an absent tsconfig.spec.json, a dependency’s unbuilt *.d.ts) is nothing to Nx and no “matched no files” warning, and the file appearing re-keys its readers. The twins spawn nothing and stay out of a run’s output and task count, so vx run build counts the tasks nx run-many -t build does. A path input inside an output one of the project’s own targets declares (TanStack/table’s public input lists {projectRoot}/dist) is generated, and gitignored where Nx caches it: Nx’s file map skips gitignored files, so Nx hashes nothing there (a changed dist file is a cache hit under Nx 23.3), and it is dropped without a todo, and the task that writes it keys its dependants through dependsOn.

OptionMeaning
rootDirectory holding nx.json. Defaults to the workspace root.
graphAn exported graph (nx graph --file=<path>) to read instead of keeping a snapshot; with it set the plugin never runs nx. Relative to root, or absolute.

Nx’s own option handling, rendered as one POSIX sh line (vx show prints it):

  • Where: the workspace root unless cwd says otherwise — a cd from the project dir, since vx has no per-task cwd — with {projectRoot}, {projectName} and {workspaceRoot} expanded.
  • How: each command runs in a shell of its own. commands run in parallel unless parallel: false (Nx’s default): all start at once, the first failure TERMs the rest and fails the task once they have exited (exit 1, as Nx), and the task succeeds once every command has (nx#28477). With parallel: false they run in order and the first failure stops the rest. commands: [] is a no-op that succeeds (nx#31345).
  • Arguments: every option run-commands does not consume is appended to each command as --name=value, then args, then whatever follows vx run … -- — per command unless forwardAllArgs is false (nx#12165). {args} takes them in place; {args.name} is the value passed after vx run … -- under that name, else the target’s options and args, spliced in as text as Nx splices it.
  • An argument replaces its option: an option the arguments after vx run … -- name (--region=us, --region us, --no-region, -r for a one-letter option; no camel-case expansion) is not appended, as Nx drops it; the line decides once it has them. tests/nx-run-commands-override-live.test.ts holds it to nx run.
  • Environment: env is exec.env.define (so it is in the key), and color: true sets FORCE_COLOR=true, as Nx does (nx#20465). envFile is loaded after the task’s .env files (below), a name they already set winning, as in Nx (nx#23581).
  • readyWhen makes the task persistent with that string as its readyWhen, so dependents start once it is printed; several strings, all of which Nx waits for, run the line under nx-env --ready-when (one flag per string), which passes the output through and prints nx-env: ready once every string has appeared on stdout or stderr; that line is the task’s readyWhen.
  • A leading Nx call (nx <target> <project>, nx run <project>:<target>, through npx / pnpm / yarn / bunx or not) in a serial line, or as its one command, is a dependsOn edge to that target (its default configuration, as Nx runs it) and leaves the line: ngrx’s build opens with nx build-package <project>. One Nx would hand options or arguments to, one after another command, and one in a parallel line stay commands.
  • Reported, not reproduced: per-command prefix / color decoration, streamOutput: false. Decoration in a serial run (parallel: false), which Nx refuses, is the failing placeholder with Nx’s reason, as readyWhen there is. Display-only options (usePty, tty, verbose) change nothing here: vx runs every task without a pseudo-terminal.

tests/nx-exec-live.test.ts holds each of these shapes to Nx’s own run-commands executor on the same options and arguments.

A workspace executor that wraps a shell command (a run-commands with your defaults) can be a command instead of an nx-exec line, which spares the Node boot each nx-exec task pays. Pass executors to nx(), a function per executor name:

import { defineWorkspace } from '@vzn/vx/config'
import { nx, type NxExecutorTarget } from '@vzn/vx-migrate'
export default defineWorkspace({
plugins: [
nx({
executors: {
'@acme/tools:run': ({ options }: NxExecutorTarget) =>
typeof options.command === 'string'
? { command: options.command, timeout: options.timeoutMs as number | undefined }
: undefined,
},
}),
],
})

Each function (NxExecutorTranslator) gets one target, an NxExecutorTarget: executor, project, projectRoot (workspace-root-relative), target, configuration (set for a <target>:<configuration> task) and the resolved options. The option’s type is NxExecutors. It returns an NxExecutorTranslation, { command, timeout?, env? }: the command runs in the project dir, timeout is exec.timeout in ms, env is defined as a run-commands env is, and the target’s .env files load as for a run-commands line. Returning undefined runs that target through nx-exec, as any executor not named. Inputs, outputs (cleaned before each run), dependsOn and caching come from the graph as for every target. nx:run-commands, nx:run-script and nx:noop (and their legacy names) are translated by nx() itself and are refused here, and so is a return that is not undefined or a valid translation. A function’s source text keys the cached mapping, so it should depend on its argument alone. bunx @vzn/vx-migrate writes executor targets as nx-exec lines regardless.

Nx loads a task’s .env files into its environment — the project’s before the workspace root’s, the most specific name first (.env.build.production.local, .env.build.production, …, .env.local, .local.env, .env), a grouped target’s named by its group’s owner and parent (cypress’s atomized e2e-ci--a.cy.ts loads .env.e2e-ci and .env.e2e), the first to define a name winning and the environment winning over every file — unless NX_LOAD_DOT_ENV_FILES=false. nx() finds the ones that exist from one listing of each project dir per run (about 4 ms at 1,000 projects) and the task loads them when it runs, with Nx’s own parser: a shell line runs under nx-env --dotenv <file>… --, an executor line passes --dotenv <file> to nx-exec. Their values never enter a config, so a .env.local secret is not in vx show, vx-lock.json or a migrated vx.config.ts. A cached task keys on their bytes through a cache.inputs.runtime probe (for f in …; do echo "$f"; cat -- "$f"; …), which sees a gitignored file a glob would not; a file added or removed changes the command, and so the key, on the next run. tests/nx-exec-live.test.ts compares what the line sees with what nx run gives the same target.

Every task gets NX_TASK_TARGET_PROJECT, NX_TASK_TARGET_TARGET, LERNA_PACKAGE_NAME (the project, which Lerna on Nx’s runner documents to scripts) and, where it runs a configuration, NX_TASK_TARGET_CONFIGURATION in exec.env.define, as Nx hands them to every task; a run-commands env wins. A package script’s nx exec -- <cmd> reads them: without them it starts Nx’s own task runner, which runs the target and its dependencies again.

{ dependentTasksOutputFiles } hashes the outputs of the tasks a target depends on; vx folds those tasks’ keys through dependsOn, and an output follows from its inputs. { workingDirectory } hashes the directory Nx was started from; a vx task runs in its project dir from wherever vx is started. Neither is a todo.

Nx hands every task its whole environment; vx hands a task only its essentials (PATH, HOME, CI, NODE_OPTIONS, …), the names its Nx { env } inputs declare (which also key it), and what nx() defines (the target, .env files, a run-commands env). A variable set in the shell and read by no declared input, NODE_ENV or DATABASE_URL, does not reach the task: declare it as an { env } input of the target in the Nx config.

Two cached tasks on one workspace path cannot both keep their cache, and the first declared is kept. Cypress’s atomizer gives e2e the whole videos dir and each e2e-ci--<spec> a subdir of it, so nx() also tries the split target (a nonAtomizedTarget) last and keeps the order that caches more tasks: each spec’s CI task keeps its cache and e2e runs uncached, with the todo. Where the specs share the split target’s one path (jest’s coverage dir) the declared order stands.

parallel (or the legacy tasksRunnerOptions.default.options.parallel) is the run’s concurrency when vx.workspace.ts sets none, and NX_PARALLEL (a count or 50%) wins over it, as in Nx: a repo that set 1 for a shared resource ran on every core under vx before.

NX_BASE, else defaultBase (Nx 19’s affected.defaultBase before it), is affectedBase when vx.workspace.ts sets none: a bare vx run test --affected compares with the ref nx affected does.

maxCacheSize (or NX_MAX_CACHE_SIZE, above it as in Nx) is the run’s cacheRetention.maxSize when vx.workspace.ts sets no retention, in Nx’s grammar (10GB, 1.5 GB, bare bytes); 0 is no cap.

lerna run build (Lerna 6+ runs it on Nx’s task runner) runs each package’s build after its dependencies’ build, unless the repo configures Nx’s task dependencies: nx.json targetDefaults (or the legacy targetDependencies), or an nx key in the package.json of a package that has the target. Beside a lerna.json with neither, each target’s dependsOn is its ^ self (build → ^build), in place of any other, as Lerna hands it to Nx; the exported graph holds no such edge. A migration writes the same.

An Nx project’s tags are its vx tags, so vx run build --filter tag:scope:web (or Nx’s --projects tag:scope:web) selects what nx run-many -t build -p tag:scope:web does. A package whose vx.config declares tags keeps its own. A blank tag is dropped (vx refuses one).

Once per run the plugin keys its snapshot (<cache dir>/nx-project-graph.json) on what Nx computes the graph from: nx.json and the files its extends chain names by content, and the worktree as git sees it — HEAD, git status -uall (core’s own for the run, DiscoverContext.worktreeChanges: the tree is walked once, which took ~96 ms per run on refine; a root elsewhere asks git itself), and the content of every listed path under a project root (the last graph’s too, so a project discover names counts before it is one), any project.json, or at the root (manifests, lockfiles, tsconfig*.json). A different key, or no snapshot, runs node_modules/.bin/nx graph --file=<snapshot> (or, where the root links no nx, a Lerna repo on pnpm, the nx Lerna depends on, which lerna run runs) — the one time Nx itself runs, served from the daemon when one is up. So a source edit that adds a cross-package import (an edge Nx derives) or a config an Nx plugin infers targets from (vite.config.ts) re-exports before the run, tracked or untracked, and a touch without an edit, or a stray file a task writes at the root, does not. Outside a git worktree the plugin falls back to the manifests’ mtimes. The snapshot and its key live in the cache dir, which ignores itself (a * .gitignore inside it), so writing them moves nothing. Nx’s own caches (.nx/cache/, .nx/workspace-data/, which the export writes) never enter the key: under a root project (a standalone repo) that does not ignore them, each export re-exported on the next run. An export that fails with a snapshot in hand warns and runs on the previous graph. The snapshot is machine-local, like the cache: nothing about it enters a key. Measured at 1,000 projects: the export runs once (1.5 s with the daemon off after an edit, 0.9 s with it on; Nx’s own computation), and a warm run with nothing changed pays the key, 43 ms at min where the manifest stats it replaced took 9 (item 1075), plus the mapping. Under vx watch the same rule runs per cycle: a project.json edit is the next cycle’s tasks, the export included — 1.3 s from the edit to the new command’s effect on that workspace. The mapping itself is kept beside it (<cache dir>/vx-migrate-nx-mapping.json), keyed on everything it reads — the graph, nx.json and its extends chain, every package manifest, each project dir’s .env names, NX_LOAD_DOT_ENV_FILES, which bins are installed and the mapper’s own code — so a warm run with nothing changed maps nothing.

  • A task the package’s own vx.config already declares is left alone — the plugin fills, it never overwrites. Migrate a package by writing its config; the rest of the repo keeps running from the graph.
  • An Nx project no workspace package matches — the root project, or an integrated repo’s project.json library no package glob lists (analogjs) — is made a project through core’s discover stage, by its package.json name or else its Nx name, and its targets attach there by directory; no vx.config is needed. One whose name a package already holds, or whose directory is gone, has nowhere to go and is reported once per run; run its targets with nx. A ^target only such a project declares is no edge, as under Nx, and an explicit edge to one of its targets is dropped with a todo (the key misses it) rather than refusing the run. A negated output (!{projectRoot}/dist/cache) takes its path back from the positive ones (core’s A-44). A cached target with no outputs takes what Nx caches for it: options.outputPath (each path of a list), or for build and prepare dist/{projectRoot} and {projectRoot}/dist; Nx’s {projectRoot}/build and {projectRoot}/public are outputs too when git tracks nothing under them; one that holds a tracked file is a todo, since vx cleans an output before the run.
  • Nx adds an nx-release-publish target (@nx/js:release-publish) to every project with a package.json; it comes along as one nx-exec task per package, run only when asked (vx run nx-release-publish --filter <pkg>), and counts in vx info’s task total. A migration does not write it: Nx release’s publish step skips a private package and a published version and rewrites workspace: ranges, which no one line does, so the report has one note naming the package manager’s own publish.
  • nx-exec and nx-env must be on every task’s PATH, which they are when @vzn/vx-migrate is a devDependency of the workspace (its bins land in node_modules/.bin); a plugin loaded by path warns once per run when they are not. Both load Nx from the workspace, so a repo run from an exported graph with no node_modules/nx warns once too.
  • A dependsOn entry’s params: "forward" (the run’s arguments) and options: "forward" (this target’s options, its configuration’s merged in) hand the dependency overrides, a different command than vx’s one task per target: each is one todo per task, naming no dependency so a run’s warnings group them (an atomized e2e-ci forwards to every spec), options only when there are options to forward.
  • Batch executors run one task per process; an executor that reads context.taskGraph under NX_BUILDABLE_LIBRARIES_TASK_GRAPH sees none and takes Nx’s project-graph path.
  • The mapping’s gaps are the migration’s gaps, reported as warnings once per run for all the tasks that carry each one. bunx @vzn/vx-migrate --dry --from nx lists the same set once.
nx-exec <executor> --project <name> --target <name> [--configuration <name>] [--options '<json>'] [--dotenv <file>]... [overrides…]
nx-env [--dotenv <file>]... [--envFile <file>] [--ready-when <string>]... -- <command> [args…]

nx-exec is a bin this package installs, and what nx() runs for every executor target. It runs under the workspace’s Node (executors are Node programs), resolves nx from the working directory up to the workspace’s node_modules, reads Nx’s cached project graph for the ExecutorContext executors expect (project root, dependencies for buildable libraries; computed in-process when the cache is missing or lacks the project, as nx run computes it, never through the daemon), replaces that project’s target in the in-memory graph with the executor and options given, and calls Nx’s own public runExecutor, so Nx’s option merging, schema defaults and validation run unchanged. The executor runs from the workspace root, where Nx forks one, though vx starts the task in its project dir: an executor that resolves against process.cwd() (@nx/js’s ts transformers, prettier’s config) sees what it sees under Nx. Anything else on the line — what vx run <target> -- --otp=123 appends — is an override, parsed by Nx’s own createOverrides and handed to the executor as nx run <p>:<t> --otp=123 would (nx#12165). --dotenv files are loaded into its environment first (see .env files above). The exit code is the outcome nx run reports: the executor’s returned result when it returns one, else its last yielded result; a server executor keeps the process alive for as long as it yields. A thrown error (an executor’s own, or a missing executor package) exits 1 with its message, and its stack only under NX_VERBOSE_LOGGING=true, as nx run reports one; an executor package that is not installed is one line naming it. The bin enables Node’s on-disk compile cache for its own process (Node ≥ 22.1, a no-op below), which takes about 30 ms off every executed task after the first.

nx-exec sets NX_DAEMON=false unless set (it never dials the daemon), and NX_VERBOSE_LOGGING=true is the context’s isVerbose.

nx-env loads the .env files and a run-commands envFile with Nx’s own functions, then runs the command with sh -c, whatever follows it appended as vx appends forwarded arguments; its exit is the shell’s. With --ready-when, the command’s stdout and stderr pass through unchanged and nx-env: ready is printed once every string has been seen (one split across two writes included).

Why the command carries the options: vx’s key sees them (resolved-config hashing holds), no ambient state decides what runs, vx show prints the truth and the line pastes into a shell. Each executed nx-exec line still loads Nx’s own module graph (about 220 ms, on a miss only): it is a bridge, and the bare command that replaces it in native config pays none of that. The design is in docs/design/nx-unchanged-2026-09.md.

bunx @vzn/vx-migrate — write the configs

Section titled “bunx @vzn/vx-migrate — write the configs”
Terminal window
bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json, nx.json or lerna.json, then vite-plus
bunx @vzn/vx-migrate --dry # print the generated files + the report instead of writing
bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
bunx @vzn/vx-migrate --from vite-task # Vite Task beside turbo.json or Nx
bunx @vzn/vx-migrate --native # write vx.config.ts files without asking
bunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo() or nx(), as `vx init` writes it
bunx @vzn/vx-migrate --help # the usage, exit 0

It is the one command a repo needs (pnpx @vzn/vx-migrate in a pnpm repo works the same):

  • In a terminal it asks which adoption you want: native (the default, a vx.config.ts per package) or keep (the workspace file vx init writes, declaring turbo() or nx()). --native / --keep answer it; with no terminal (CI, a pipe) it is native.
  • With no vx.workspace.* yet, it writes one declaring the plugins the repo calls for (src/workspace-plugins.ts): the @vzn/vx-lockfile factory for the lockfile (pnpm(), bun(), npm(), yarn()), scheduleHistoryPlugin(), and github() when .github/workflows exists, from @vzn/vx-ci, each installed beside @vzn/vx at vx-migrate’s own version. Keep adds them to the file vx init writes, or to one already in that shape; any other workspace file is the user’s and left alone. Native over a file vx wrote (a keep adoption’s) rewrites it: turbo() and nx() go, since the configs written replace them, and the other plugins stay.
  • It installs what the written files import with the repo’s own manager (packageManager, else the lockfile): @vzn/vx, the declared plugins’ packages, and @vzn/vx-migrate for keep or when a written task runs its nx-exec or nx-env bin (pnpm add -D -w, yarn add -D -W on Yarn 1 and without -W on Yarn 2+, bun add -d, npm install -D). A package the root both lists and has installed at vx-migrate’s own version is left alone; any other is installed at that version. Native removes a listed @vzn/vx-migrate when nothing written needs it (no nx-exec / nx-env task, no turboCache() or nxCache() left in the workspace file): pnpm remove -w, yarn remove (-W on Yarn 1), bun remove, npm uninstall. --no-install skips both.

A Lerna repo is an Nx one: Lerna 6+ runs lerna run on Nx’s task runner over the graph nx graph exports, nx.json or not, so a lerna.json with no nx.json and no turbo.json is mapped from that graph, and keep writes the workspace file declaring nx() (vx init adopts by nx.json, and would map the scripts). Lerna is the installed one, else the root manifest’s range; with neither, the lerna.json is another tool’s (lerna-lite reads it too, and runs no Nx). A root whose scripts never lerna run runs its tasks another way and publishes with Lerna (webdriverio: run-s, pnpm -r), so its scripts stay the source. Not where Lerna runs its own runner: useNx: false, or Lerna 5 without useNx: true; those are vx init’s scripts mapping. Beside turbo.json, Turbo runs the tasks and Lerna only publishes: turbo.json is the source.

Your package.json scripts are never edited. --dry installs nothing and says what it would install.

--dry prints the files instead of writing them; --force overwrites existing ones; --mjs writes vx.config.mjs (and vx-preset.mjs) instead of .ts — the same objects with no type import and no satisfies, for a package whose own tsconfig includes every .ts under it and would compile the config into its dist (TanStack/query, 2026-09-11).

package.json scripts are core’s own vx init. What this package writes reads exactly like what vx init writes: both hand a plan to core’s migration seam (applyMigration from @vzn/vx), which renders, refuses to overwrite without --force, writes and reports. Anything a source cannot say becomes a TODO(vx-migrate) comment, never a silent wrong value.

Reads the root pipeline (tasks in Turbo 2, pipeline in Turbo 1), per-package turbo.json extends overlays and each package’s scripts, through the same mapper turbo() runs live — so the written configs say what the plugin was doing. Turbo’s global fields become a generated root vx-preset.ts each config imports and spreads: TypeScript composition replaces global config. A package whose tsconfig.json is a composite project (or sets rootDir) and takes in its config declares the values it uses instead: tsc refuses a file outside the package (TS6059, TS6307; withastro/astro’s scripts/). So does a task’s env that several configs share (buildEnv), where inline it would repeat in every package (vercel/ai: 63 names, twice in each of ~100 configs, half the output). Turbo’s //# root tasks are written to a vx.config.ts at the workspace root, which makes the root a project (core’s D-39), and a package task’s //#x edge reaches it. When a vx.workspace.ts already exists without one, the report names the @vzn/vx-lockfile plugin for the repo’s lockfile: Turbo keys each package on its own lockfile entries, and core keys every task on the whole file until a plugin claims it.

Turborepovx
dependsOndependsOn, same micro-syntax ($TURBO_ROOT$ deps are a TODO)
inputscache.inputs.files ($TURBO_DEFAULT$ → **/*; $TURBO_ROOT$/x → workspaceFiles)
outputscache.outputs.files, a negated one included
envcache.inputs.env and exec.env.passThrough (child envs are isolated)
passThroughEnvexec.env.passThrough
cache: false / persistent: trueno cache block; exec.persistent: {}, with a TODO to set readyWhen when a task depends on it

Reads the resolved project graph only (.nx/workspace-data/project-graph.json when exported, else the one the workspace’s own nx graph exports into a temp file, as nx() does; with no node_modules/.bin/nx, as in a fresh clone, it stops and names the repo manager’s install), through the same mapper nx() runs live, executors aside (below). Targets Nx plugins infer at runtime are frozen as the snapshot saw them. nx:run-commands is the one shell line nx() runs (see nx:run-commands above: where, parallel or in order, forwarded arguments, env, readyWhen) — storybook’s compile is cd ../../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli; a plain command is that shorthand; nx:run-script is the package’s script body with its pre<name> / post<name> hooks folded in (or yarn run <name> when the body calls yarn’s run builtin; an empty script is the placeholder with a todo), nx:noop is a group task; Nx 15–16’s @nrwl/workspace:run-commands and run-script (and their @nx/workspace: names) are the nx: executors they re-exported; an executor target is its nx-exec line, as nx() runs it: the migrator translates no executor, so Nx and @vzn/vx-migrate stay installed until each such line is rewritten as the command it runs (a server executor Nx knows, @nx/js:node or a dev server, is still a persistent task). The report also says what vx.workspace.ts still holds, as the Turbo migration does: the nx() vx init declared, which keeps reading nx.json, and a lockfile with no @vzn/vx-lockfile plugin, where Nx keyed each project on the npm packages it depends on. A written command that still runs Nx itself (a run-commands nx run b:build, npx nx test b, a script’s nx exec -- tsc) carries a TODO: it works only while Nx is installed. An Nx project no workspace glob lists (an integrated repo’s project.json library) gets a package.json (name, private) where it has none, and a note names the directories to add to workspaces (or pnpm-workspace.yaml): core finds a project only through those globs, and the migrator never edits the root manifest. A target with configurations writes one task per configuration (build, build:ci). A project’s Nx tags are written as its tags. Named inputs expand from nx.json when readable. An output path is kept as written, a ! one too ({projectRoot}/dist → dist, {projectRoot}/bin/tool → bin/tool; one naming an unset {options.x} is dropped, as Nx drops it; a dotted {options.outputPath.base} walks the options, as Nx does; an extglob is put in vx’s grammar: Next’s inferred .next/!(cache)/**/* is .next/*/**/* with !.next/cache/**/*, @(js|map) is {js,map}, and a form with no vx spelling is dropped with a TODO): vx reads a bare path as the file or the whole tree under it, so a directory and an extensionless binary both save and restore. nx.json’s parallel, defaultBase and maxCacheSize, which nx() applies live, are each a field of the vx.workspace.ts it writes (concurrency, affectedBase, cacheRetention.maxSize), read from nx.json alone, never the environment; beside a workspace file already there, or for a size vx cannot read (1.5 GB), each is a note naming the field to add. vx derives package edges from package.json; an Nx graph edge with no manifest path (implicitDependencies, a tsconfig path) becomes, for each ^target of the dependant, an explicit pkg#target edge to what Nx’s own walk reaches — each dependency that has the target, and through one that lacks it, its dependencies — so the order and the key are Nx’s. The reverse too: where a project’s manifest names a workspace package its Nx graph does not reach (an implicitDependencies: ["!a"] that breaks a manifest cycle), its ^target becomes the explicit edges Nx draws, since vx’s ^ would follow the manifest and bring the cycle back.

Detected when the root package.json lists vite-plus, after turbo.json and Nx: vite-plus is a whole toolchain, so a repo with either of those runs its tasks there; --from vite-task picks Vite Task anyway. There is no live plugin, so --keep is refused. Each package’s vite.config.* (vite-plus’s file order) is loaded by Bun as vp run sees it, a function config called in build mode, and its run block mapped with the package’s package.json scripts, which vp run runs too. The root package’s tasks and scripts (vp run -w) make it a project when it has a name. A command that opens with vp run (or vpr) is inlined as Vite Task inlines it: vp run x, pkg#x, -r x, -w x and -F <name> x are dependsOn edges (-r to each package with x, the task’s own reference pruned), and the task is a group when nothing follows them. They run in parallel where Vite Task ran a chain in order; any other form (--parallel, -t, forwarded arguments, a vp run after another command) stays in the command with a TODO.

Vite Taskvx
command (a string, or an array run in order)exec.command, the array joined with &&; command: [] is a group task
cwdcd <cwd> && before the command
dependsOn: 'x' / 'pkg#x'the same
dependsOn: { task, from }^task; when a member listed under a field from leaves out also has the task, the explicit pkg#task edges, since ^ follows every field
cache.input / cache.output globscache.inputs.files / cache.outputs.files; base: 'workspace' → workspaceFiles, a root task’s too
omitted or { auto: true } (traced files)no cache block, with a TODO: vx infers no inputs or outputs
cache.envcache.inputs.env and exec.env.passThrough; a * entry lists the names the package’s tracked files and its workspace dependencies’ spell (a TODO when none match); ! entries take names back
cache.untrackedEnvexec.env.passThrough, wildcards as cache.env
cache: false, root run.cache false / tasks: falseno cache block
a package.json scripta task, uncached; with root run.cache.scripts a TODO for its cache
run.enablePrePostScripts (default true)a script’s pre<name> / post<name> folded into its command; false keeps them tasks of their own

Store vx artifacts in any server speaking Turbo’s /v8/artifacts API — Vercel’s hosted cache or a self-hosted implementation of the published OpenAPI spec (Bearer auth, x-artifact-duration, HMAC-SHA256 x-artifact-tag signatures). The wire is theirs; the bytes are vx’s own artifacts under vx’s own keys. The server is storage — the other tool cannot read what vx stores there, and vx does not read its entries.

Nothing is on by default. Declare the plugin in vx.workspace.ts and configure it explicitly. Reads try the local cache first and the remote only on a local miss:

import { defineWorkspace } from '@vzn/vx/config'
import { turboCache } from '@vzn/vx-migrate'
export default defineWorkspace({
plugins: [
turboCache({
apiUrl: 'https://cache.example.com',
token: process.env.CACHE_TOKEN,
teamSlug: 'acme',
// Optional: sign uploads and verify downloads (Turbo's artifact signature).
// signatureKey: process.env.CACHE_SIGNATURE_KEY, teamId: 'team_acme',
}),
],
})

Every option falls back to the tool’s own environment variable, so a self-hosted setup carries over unchanged. A token with no apiUrl means Vercel’s hosted Remote Cache (https://vercel.com/api), exactly as it does for turbo — so npx turbo login && npx turbo link, then turboCache() with TURBO_TOKEN / TURBO_TEAM set, is the whole hosted setup. Below the environment, in Turbo’s order: a Vercel build’s VERCEL_ARTIFACTS_TOKEN and VERCEL_ARTIFACTS_OWNER (the team id, where TURBO_TOKEN with a team is not set), then the repo’s .turbo/config.json (what turbo link writes: apiUrl, teamId, teamSlug, token), then the root turbo.json’s remoteCache (apiUrl, teamId, teamSlug), whose enabled: false declines unless the options name a cache. With no token the plugin declines and the run stays local.

OptionEnvironment variableMeaning
apiUrlTURBO_APIbase URL of the cache server; default with a token: https://vercel.com/api; a user:pass@ in it is refused
tokenTURBO_TOKENBearer token on every request
teamIdTURBO_TEAMIDteamId query parameter; required with signatureKey
teamSlugTURBO_TEAMslug query parameter
signatureKeyTURBO_REMOTE_CACHE_SIGNATURE_KEY, read only under turbo.json’s remoteCache.signature: true (or TURBO_SIGNATURE), as Turbo reads itHMAC-SHA256 key (≥ 32 bytes, used raw); a download whose tag does not verify is a miss
timeoutMsTURBO_REMOTE_CACHE_TIMEOUT, then remoteCache.timeout (seconds)HEAD/GET/POST deadline (default 30 s; 0 none)
uploadTimeoutMsTURBO_REMOTE_CACHE_UPLOAD_TIMEOUT, then remoteCache.uploadTimeout (seconds)PUT deadline (default 60 s; 0 none)
retries—resends of a request answered 429 / 5xx (not 501) or never connected (default 1, Turbo’s); 0 turns them off
preflightTURBO_PREFLIGHT (1 or 0), then remoteCache.preflightsend Turbo’s OPTIONS preflight before each artifact request and go where its Location points (relative to apiUrl); the token goes along only when the API’s Access-Control-Allow-Headers admits Authorization; a redirected preflight is not followed (default off)

The signature is Turbo’s current scheme (artifact-signature:v2: prefix, hash, team id and body, each length-prefixed, under HMAC-SHA256, base64 in x-artifact-tag). A signed body is written to a temp file before its tag can be checked, so one past core’s artifact ceiling (2 GiB, at zstd’s bound) is refused as it passes it, and is a miss.

Artifacts stream both ways on both wires: an upload sends the local artifact from its file, a download hands vx the response body to write straight to disk. A signed download must verify before vx sees a byte, so it is written to a temp file in vx’s cache directory (which the sandbox walls, unlike the shared temp dir a sandboxed task may read) and verified from there (the tag covers the body’s length, which a chunked response does not declare up front); a bad tag deletes the temp and reads as a miss, and a good one is handed over as a stream that deletes the temp once it is read or cancelled. A process that exits first (vx’s Ctrl-C exit awaits no stream) deletes it on the way out.

nxCache() — an Nx self-hosted remote cache

Section titled “nxCache() — an Nx self-hosted remote cache”

Store vx artifacts in any server implementing Nx’s remote cache OpenAPI spec (GET/PUT /v1/cache/{hash}, Bearer auth, immutable records — a second write of a hash is 409, which the plugin treats as done). Same rule: the wire is theirs, the bytes are vx’s. A download asks for Accept: application/octet-stream, as Nx’s own client does: an API gateway that keys binary media on Accept base64-encodes anything else (nx#33092); turboCache() asks the same way.

import { defineWorkspace } from '@vzn/vx/config'
import { nxCache } from '@vzn/vx-migrate'
export default defineWorkspace({
plugins: [nxCache({ server: 'https://cache.example.com', accessToken: process.env.CACHE_TOKEN })],
})

Every option falls back to the tool’s own environment variable; with nothing configured the plugin declines and the run stays local.

OptionEnvironment variableMeaning
serverNX_SELF_HOSTED_REMOTE_CACHE_SERVERbase URL of the cache server; a user:pass@ in it is refused
accessTokenNX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKENBearer token; omit for a server that runs open
timeoutMs—per-request deadline (default 30 s; a positive number of ms)
retries—resends, as turboCache()’s (default 1)

The Nx spec has no existence probe, so has (the --dry prediction; the prefetch pass calls get) is a GET whose body is cancelled before it answers. The wire carries no producing-task duration, so a remote hit reports none.

  • A remote error degrades to a miss and a warning that names the request, the artifact and the server — vx/turbo-cache: upload 32248a2a7c89e241 to https://cache.example.com/v8/artifacts failed: no answer within 60000 ms — never the token. The run never fails because of the cache.
  • The same failure is said once per run: an unreachable server fails the probe, the download and the upload alike, and the run prints the first and, at its end, vx/turbo-cache: 2 more requests failed the same way: Unable to connect. Is the computer able to access the url? (core’s LayeredCache does the counting, for every cache plugin).
  • A request answered 429 or 5xx (not 501), or one that never connected (a refused port, an unresolved host), is sent again after 2 s — a 429 after its Retry-After, capped at 10 s — as Turbo’s client does. A spent deadline is not: it has already cost its wait.
  • Three outages in a row (no connection, no answer within the deadline, or a 5xx once the resends are spent) open Turbo’s outage breaker: no request is sent for 30 s, each lookup is a miss at once, then one request probes and its answer closes or reopens it. A hit is judged when its body ends, so a server that sends headers and then stalls the body counts the same way. A hung server costs three deadlines, not one per task.
  • A refused token (401/403) warns once and turns the layer off for the rest of the process (a 403 on an upload is a read-only token — Nx’s spec, or turborepo-remote-cache’s READ_ONLY and write-less JWTs: it turns off uploads alone, and reads go on) — including the requests already in flight when the refusal lands, which degrade in silence rather than repeating it (a six-project run printed five identical lines before 2026-09-20).
  • Policy (--cache=remote:r, …) is enforced by core’s LayeredCache, which the plugins wrap — a read-only token pairs naturally with remote:r.
  • Each tool’s own switches narrow that policy, never widen it, as they do for the tool: TURBO_CACHE (--cache’s syntax, an omitted source off) and TURBO_REMOTE_CACHE_READ_ONLY for turboCache(), NX_SKIP_REMOTE_CACHE / NX_DISABLE_REMOTE_CACHE (true) for nxCache(). A CI that keeps untrusted pull requests off the shared cache that way keeps vx off it too.

bun test runs the Turbo and Nx plugins over fixture workspaces (the Nx one against a fake nx whose graph export and runExecutor are stubs, so the vx → nx-exec → executor → cache round trip is real), the migrate CLI over both sources, and each remote-cache wire against a strict in-memory implementation of its spec plus a full vx run round trip (miss → upload → local wipe → restore from the server). A separate suite points both plugins at a HOSTILE server — 500 on every request, 401, a server that never answers, and a body that is not an artifact — and pins that each one degrades to a miss with the run still green. tests/nx-exec-live.test.ts runs nx-exec against REAL Nx when VX_NX_MODULES names a directory whose node_modules holds nx, @nx/js and typescript (CI installs one under packages/vx-migrate/.nx-live and sets VX_REQUIRE_NX=1, so an absent install fails there instead of skipping).

vx migrate was a core verb until 2026-09-10; it moved here so core reads no other runner’s format. @vzn/vx-turbo, @vzn/vx-turbo-cache and @vzn/vx-nx-cache were separate packages until 2026-09-11, when adoption became one package. Typing vx migrate prints the pointer here.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.