@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’sprojectstage fromturbo.jsonand each package’spackage.jsonscripts 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 throughnx-exec, one executor per process.bunx @vzn/vx-migrate— write onevx.config.tsper workspace package from yourturbo.json, an exported Nx project graph, or vite-plus’srun.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()andnxCache()— keep the remote cache you have: any server speaking Turbo’s/v8/artifactsAPI (Vercel’s hosted cache included) or Nx’s self-hosted/v1/cachespec.
npm install -D @vzn/vx @vzn/vx-migrate # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -dIn 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 cacheimport { 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.
turbo() — a Turbo repo, mid-migration
Section titled “turbo() — a Turbo repo, mid-migration”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.
| Option | Meaning |
|---|---|
root | Directory holding turbo.json. Defaults to the workspace root. |
Locking
Section titled “Locking”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.
What it does not do
Section titled “What it does not do”- A task the package’s own
vx.configalready declares is left alone — the plugin fills, it never overwrites. Migrate a package by writing its config; the rest of the repo keeps running fromturbo.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 375devtargets a run).bunx @vzn/vx-migrate --drylists the same set once. Each gap is one line per run for every task that carries it, not one per task (n8n marksdevandwatchpersistent in most of its 84 packages; astro negatesvendor/**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 aworkspaceFilesglob. A negated output under a literal-rooted one (dist/**minus!dist/**/*.map) takes its paths back (seeoutputsbelow); 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.configfiles 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 everyturbo.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, apackage.jsonscript edit or a per-packageturbo.jsonoverlay edit is the next cycle’s tasks. The rootturbo.jsonis no task’s input and lives in no project dir, so an edit to it is not a cycle — restart the watch.
The mapper (mapTurboWorkspace)
Section titled “The mapper (mapTurboWorkspace)”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 atask: nullentry whose todo says why, rather than a config that fails to load. -
Definition order: root
pkg#nameif there is one, else rootname(the first replaces the second whole, as Turbo looks it up), then each package config its ownturbo.jsonextends ("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’sfoldScriptHooks: 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’sprebuildcopies the CSS itsbuildinlines), one process less per task thanpnpm run <name>— except a body that calls yarn’srunbuiltin (run -T rollup -c,run clean && run build; yarn ≥ 2 runs scripts in its own shell), which isrun: command not foundin sh: it runs asyarn 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.ymlwith nonodeLinker, orpnp) has nonode_modules: a dependency resolves only through.pnp.cjsand a bin only through yarn’s shims, so there every script runs asyarn run <name>, and anodeLinkerchange maps afresh. Same rules fornx:run-scriptbelow (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, nopre/posthooks — and gives the package the task even with no script (turborepo’s@turbo/types#build,docs#schema);nullor[]is Turbo’s no-op node, no task even where the script exists, its edges passed through; a per-toolchain map applies itsjavascriptentry (ortypescript, Turbo’s alias), and without one the script runs. A shape Turbo would refuse keeps the script, with a todo. -
dependsOn:^xpasses through when some package runsxor turbo.json defines it, and is dropped otherwise (core refuses a^xno project declares as a typo);pkg#taskis kept only whenpkgemitstask(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 → ^buildwith nocodegenscript istest → ^build); another package’s^xorpkg#xreaches such a node through a group task when its edges name a task of its own package or another (with-tailwind’suihas nobuildscript; itsbuildbuildsbuild:stylesandbuild:components, andweb#buildwaits on them), and so does one whose^edge names another task (rallly’s script-lessbuildholds^db:generate), while one whose only edge is^to its own name needs none, since core’s^xwalks past a package withoutx; 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’sturbo run type-checkbuilds each example through its script-lesstype-check: [build], and kitchen-sink’sturbo run testbuilds@repo/uiforadmin, which has notestscript); 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’slint, shadcn-ui’scheck) is a group task in each package that defines it, its edges kept (apkg#taskedge only inpkg), sovx run lintruns Turbo’s no-op nodes and exits 0 asturbo run lintdoes. Root//#tasks run in the workspace root, as under Turbo:turbo()names the root a project through core’sdiscoverstage when turbo.json declares one (novx.configneeded; a root no member glob lists), and//#xindependsOnis an edge to it, named by the rootpackage.jsonname; 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 rootpackage.jsonwith noname, 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 doesturbo(); in a single-package repo (no workspaces, Turbo’snon-monorepoexample) 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, andtest: { 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^transitedge, so a dependant’stestre-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. Abuildwhose only edge is^buildis not written: core gives every project with nobuildthat node (solid’s three script-less packages each got abuildof their own). A dependency without the script whose own edges lack^xgets it too, so a^xstops there as Turbo’s does (opencode’sbuild: { dependsOn: [] }: core walked past to the builds below, which Turbo’s^buildnever reaches). -
with(tasks Turbo runs alongside,web#devwithapi#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 whosewithnames persistent sidecars (Turbo’s with-tailwind example:uihas nodevscript, itsdevstartsdev:stylesanddev:components) is a group task that depends on them. -
inputs: a structured entry (Turbo 2.11) is itsglobs, plus**/*withwithDefaults, forstartupandjitalike;dependencyOutputsadds none where the producer is cached (vx folds each dependency’s key); from acache: falseproducer (vercel/vercel’s//#generate:cache-keys, which records the host) the named files, or its outputs, are acache.inputs.workspaceRuntimeprobe, andcache.inputs.tasksleaves 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.globalDependenciesand Turbo 1’sglobalDotEnvland inworkspaceFilestoo, and a task’s Turbo 1dotEnvin itsfiles. Turbo 1’s$NAMEentries, inglobalDependenciesor a task’sdependsOn, are env vars: they joincache.inputs.envandexec.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 incache.inputs.workspaceFiles. Turbo’s glob grammar is translated as Nx’s is (*.[jt]sis*.{[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 1dotEnv, create-turbo’sglobalDependencies: ["**/.env.*local"]) is gitignored as a rule, so it is keyed by a probe that hashes the.envfiles 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 afind -namethat skipsnode_modules, and another**walks the workspace), rather than as a file glob git never reports. A package whose.envglobs 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_modulesis never an output), and so does a first segment no tracked top-level entry matches (tldraw’sdist-*/**). 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 ownpackage.json(trpc’s client build lists it: the build rewritesexports) 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.jsoninstead and keeps the cache (owner, 2026-10-03). So does one that covers the package’s vx.config;turbo()andnx()check the config the package has, andvx-migratethe one it writes, so sanity’s*.jsshims 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’sdata/sponsors.jsonin a cacheddata) 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()andnx()both do this, and so do the configsvx-migratewrites 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 tocache.inputs.env(env only) andexec.env.passThrough(both, plus both globals); a wildcard expands over the run’s environment underturbo(), 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’sE2E_*), 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’sNEXT_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 rootbuildenv\*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 ofexec.env.passThrough, asshdrops it before the task runs. -
Framework inference (Turbo hashes and passes Next’s
NEXT_PUBLIC_*, Vite’sVITE_*, … 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.exampleof the package and its workspace dependencies spell (the bare prefix, as astartsWithtest 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:codeandbuild:types, all ondist/**): 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 defaultrules.exclusiveOutputsan edge between the two changes nothing (twenty’sbuild:individualwriting intobuild’sdist). 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
testkeyed on**/*beside abuildwritingdist/**): vx’s defaultrules.upfrontKeysrefuses 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’seslint-suppressions.jsonin alinttask’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-installwrites../../node_modules/@prisma/client/**from every package with the script): core refuses two cached tasks on one path with no edge between them, soturbo()(andnx()) 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 cachesdistand each package’s typecheckdist/packages/<name>, one path twice. Edges across packages are not read, so an ordered pair loses its cache too. -
cache: falseorpersistent: true→ nocacheblock; a persistent task getsexec.persistent: {}and, when some task depends on it, the consumer’spersistentTodo. -
A task’s
description(Turbo 2.11.5’s schema) is the vx task’sdescription. -
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). -
interruptiblemaps to nothing:vx watchkeeps 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 inglobal, orTURBO_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 whatvx.workspace.tsleaves 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.5GBor bare bytes, restated whole:7680MB) →cacheRetention.maxSize/.olderThan.TURBO_CONCURRENCY,TURBO_CACHE_MAX_SIZEandTURBO_CACHE_MAX_AGEwin over turbo.json, as under Turbo; a set one decides, its0included. -
TURBO_SCM_BASE, the baseturbo run --affectedcompares with, isaffectedBasewhenvx.workspace.tssets none: a barevx run build --affectedcompares with the same ref. With it unset on GitHub Actions (GITHUB_ACTIONS), Turbo’s own base applies: a pull request’sGITHUB_BASE_REF, else the push event’sbeforefromGITHUB_EVENT_PATH. -
Turbo hashes each package’s
microfrontends.json(or.jsonc) into every task, or the one fileVC_MICROFRONTENDS_CONFIG_FILE_NAMEnames; so doesturbo(). -
Turbo 2.11’s
globalblock (futureFlags.globalConfiguration) is read as theglobalDependencies,globalEnvandglobalPassThroughEnvit replaces. -
An unknown Turbo key is a todo naming it;
extendsis accepted and ignored (the overlay order above is what it means).
nx() — an Nx repo, mid-migration
Section titled “nx() — an Nx repo, mid-migration”// vx.workspace.ts — an Nx repo, mid-migrationimport { 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.
| Option | Meaning |
|---|---|
root | Directory holding nx.json. Defaults to the workspace root. |
graph | An 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:run-commands
Section titled “nx:run-commands”Nx’s own option handling, rendered as one POSIX sh line (vx show prints it):
- Where: the workspace root unless
cwdsays otherwise — acdfrom 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.
commandsrun in parallel unlessparallel: 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). Withparallel: falsethey 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, thenargs, then whatever followsvx run … --— per command unlessforwardAllArgsis false (nx#12165).{args}takes them in place;{args.name}is the value passed aftervx run … --under that name, else the target’s options andargs, 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,-rfor 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.tsholds it tonx run. - Environment:
envisexec.env.define(so it is in the key), andcolor: truesetsFORCE_COLOR=true, as Nx does (nx#20465).envFileis loaded after the task’s.envfiles (below), a name they already set winning, as in Nx (nx#23581). readyWhenmakes the task persistent with that string as itsreadyWhen, so dependents start once it is printed; several strings, all of which Nx waits for, run the line undernx-env --ready-when(one flag per string), which passes the output through and printsnx-env: readyonce every string has appeared on stdout or stderr; that line is the task’sreadyWhen.- A leading Nx call (
nx <target> <project>,nx run <project>:<target>, throughnpx/pnpm/yarn/bunxor not) in a serial line, or as its one command, is adependsOnedge to that target (its default configuration, as Nx runs it) and leaves the line: ngrx’sbuildopens withnx 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/colordecoration,streamOutput: false. Decoration in a serial run (parallel: false), which Nx refuses, is the failing placeholder with Nx’s reason, asreadyWhenthere 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.
Your own executors
Section titled “Your own executors”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.
.env files
Section titled “.env files”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.
The target a task sees
Section titled “The target a task sees”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.
Inputs that key nothing here
Section titled “Inputs that key nothing here”{ 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.
The environment a task sees
Section titled “The environment a task sees”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.
Atomized targets
Section titled “Atomized targets”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.
nx.json parallel
Section titled “nx.json parallel”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.json defaultBase
Section titled “nx.json defaultBase”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.
nx.json maxCacheSize
Section titled “nx.json maxCacheSize”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.
Project tags
Section titled “Project tags”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).
The graph snapshot
Section titled “The graph snapshot”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.
What it does not do
Section titled “What it does not do”- A task the package’s own
vx.configalready 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.jsonlibrary no package glob lists (analogjs) — is made a project through core’sdiscoverstage, by itspackage.jsonname or else its Nx name, and its targets attach there by directory; novx.configis 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 withnx. A^targetonly 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 nooutputstakes what Nx caches for it:options.outputPath(each path of a list), or forbuildandpreparedist/{projectRoot}and{projectRoot}/dist; Nx’s{projectRoot}/buildand{projectRoot}/publicare 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-publishtarget (@nx/js:release-publish) to every project with apackage.json; it comes along as onenx-exectask per package, run only when asked (vx run nx-release-publish --filter <pkg>), and counts invx info’s task total. A migration does not write it: Nx release’s publish step skips a private package and a published version and rewritesworkspace:ranges, which no one line does, so the report has one note naming the package manager’s ownpublish. nx-execandnx-envmust be on every task’s PATH, which they are when@vzn/vx-migrateis a devDependency of the workspace (its bins land innode_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 exportedgraphwith nonode_modules/nxwarns once too.- A
dependsOnentry’sparams: "forward"(the run’s arguments) andoptions: "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 atomizede2e-ciforwards to every spec),optionsonly when there are options to forward. - Batch executors run one task per process; an executor that reads
context.taskGraphunderNX_BUILDABLE_LIBRARIES_TASK_GRAPHsees 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 nxlists the same set once.
nx-exec — one Nx executor, one process
Section titled “nx-exec — one Nx executor, one process”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”bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json, nx.json or lerna.json, then vite-plusbunx @vzn/vx-migrate --dry # print the generated files + the report instead of writingbunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.tsbunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked inbunx @vzn/vx-migrate --from vite-task # Vite Task beside turbo.json or Nxbunx @vzn/vx-migrate --native # write vx.config.ts files without askingbunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo() or nx(), as `vx init` writes itbunx @vzn/vx-migrate --help # the usage, exit 0It 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.tsper package) or keep (the workspace filevx initwrites, declaringturbo()ornx()).--native/--keepanswer 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-lockfilefactory for the lockfile (pnpm(),bun(),npm(),yarn()),scheduleHistoryPlugin(), andgithub()when.github/workflowsexists, from@vzn/vx-ci, each installed beside@vzn/vxat vx-migrate’s own version. Keep adds them to the filevx initwrites, 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()andnx()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-migratefor keep or when a written task runs itsnx-execornx-envbin (pnpm add -D -w,yarn add -D -Won Yarn 1 and without-Won 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-migratewhen nothing written needs it (nonx-exec/nx-envtask, noturboCache()ornxCache()left in the workspace file):pnpm remove -w,yarn remove(-Won Yarn 1),bun remove,npm uninstall.--no-installskips 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.
| Turborepo | vx |
|---|---|
dependsOn | dependsOn, same micro-syntax ($TURBO_ROOT$ deps are a TODO) |
inputs | cache.inputs.files ($TURBO_DEFAULT$ → **/*; $TURBO_ROOT$/x → workspaceFiles) |
outputs | cache.outputs.files, a negated one included |
env | cache.inputs.env and exec.env.passThrough (child envs are isolated) |
passThroughEnv | exec.env.passThrough |
cache: false / persistent: true | no 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.
Vite Task
Section titled “Vite Task”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 Task | vx |
|---|---|
command (a string, or an array run in order) | exec.command, the array joined with &&; command: [] is a group task |
cwd | cd <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 globs | cache.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.env | cache.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.untrackedEnv | exec.env.passThrough, wildcards as cache.env |
cache: false, root run.cache false / tasks: false | no cache block |
a package.json script | a 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 |
turboCache() — a Turbo remote cache
Section titled “turboCache() — a Turbo remote cache”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.
| Option | Environment variable | Meaning |
|---|---|---|
apiUrl | TURBO_API | base URL of the cache server; default with a token: https://vercel.com/api; a user:pass@ in it is refused |
token | TURBO_TOKEN | Bearer token on every request |
teamId | TURBO_TEAMID | teamId query parameter; required with signatureKey |
teamSlug | TURBO_TEAM | slug query parameter |
signatureKey | TURBO_REMOTE_CACHE_SIGNATURE_KEY, read only under turbo.json’s remoteCache.signature: true (or TURBO_SIGNATURE), as Turbo reads it | HMAC-SHA256 key (≥ 32 bytes, used raw); a download whose tag does not verify is a miss |
timeoutMs | TURBO_REMOTE_CACHE_TIMEOUT, then remoteCache.timeout (seconds) | HEAD/GET/POST deadline (default 30 s; 0 none) |
uploadTimeoutMs | TURBO_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 |
preflight | TURBO_PREFLIGHT (1 or 0), then remoteCache.preflight | send 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.
| Option | Environment variable | Meaning |
|---|---|---|
server | NX_SELF_HOSTED_REMOTE_CACHE_SERVER | base URL of the cache server; a user:pass@ in it is refused |
accessToken | NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN | Bearer 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.
Remote-cache behaviour (both)
Section titled “Remote-cache behaviour (both)”- 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’sLayeredCachedoes the counting, for every cache plugin). - A request answered
429or5xx(not501), or one that never connected (a refused port, an unresolved host), is sent again after 2 s — a429after itsRetry-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
5xxonce 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 (a403on an upload is a read-only token — Nx’s spec, or turborepo-remote-cache’sREAD_ONLYand 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’sLayeredCache, which the plugins wrap — a read-only token pairs naturally withremote: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) andTURBO_REMOTE_CACHE_READ_ONLYforturboCache(),NX_SKIP_REMOTE_CACHE/NX_DISABLE_REMOTE_CACHE(true) fornxCache(). A CI that keeps untrusted pull requests off the shared cache that way keeps vx off it too.
Testing
Section titled “Testing”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).
History
Section titled “History”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.