Migrate
One command moves a Turborepo or Nx workspace to vx: a vx.config.ts per
package that you keep, each task running its tool directly. Any other workspace
starts at the quickstart.
-
Run init at the workspace root
Terminal window bunx @vzn/vx initBeside
turbo.jsonornx.json, it runs@vzn/vx-migrate, which writes onevx.config.tsper package,.vx/workspace.tsand the lock.It never overwrites a file without
--force. -
Review the TODOs
Search for
TODO(vx-migrate). Each names a choice the migrator could not make for you. -
Run it
Terminal window vx run build --allThe first run misses every task once: the lockfile plugin and the install change each key (
vx why <task>names both). The run after that hits. -
Delete the old config
Once vx does what
turbo run buildornx run-many -t builddid, deleteturbo.jsonornx.json: the configs declare every task it mapped.
What init does
Section titled “What init does”vx init installs @vzn/vx globally and in the workspace. Then it declares
what the workspace needs in .vx/workspace.ts: its lockfile plugin,
vxHistory(), and github() when .github/workflows exists.
| Flag | Does |
|---|---|
--native |
writes vx configs (the default without a terminal) |
--keep |
runs the old config as it is (check the mapping first) |
Turborepo
Section titled “Turborepo”Beside turbo.json or turbo.jsonc, init writes one vx.config.ts per
package, plus a vx-preset.ts when turbo.json has global fields or a task
env several packages share.
Check the mapping first
Section titled “Check the mapping first”bunx @vzn/vx init --keep writes only this .vx/workspace.ts, installs
what it imports, and writes the lock.
Its next: line builds once through turbo(). That checks the mapping
holds; it is not a way to keep running the workspace.
import type { WorkspaceConfig } from '@vzn/vx/config'import { turbo } from '@vzn/vx-migrate'import { npm } from '@vzn/vx-lockfile'import { vxHistory } from '@vzn/vx-history'
export default { plugins: [ turbo(), npm(), vxHistory(), ],} satisfies WorkspaceConfigbunx @vzn/vx init --keep vx-migrate 0.0.0 from turbo.json, run by turbo() from @vzn/vx-migrate ran npm install -D @vzn/vx-lockfile @vzn/vx-history files .vx/workspace.ts lock 0 project configs → .vx/projects.lock.json (and the tasks a plugin maps for app, lib) note a temporary start: vx init --native writes native config
next vx run build --allWhere the workspace shows a Turbo remote cache, the file declares
turboCache() too, and init says why:
turboCache(): .github/workflows/ci.yml sets TURBO_TOKEN, so vx shares that remote cache (token: the variable, else the OS keychain).
bunx @vzn/vx-migrate then writes the configs (--dry previews them)
and takes turbo() out of .vx/workspace.ts:
bunx @vzn/vx-migrate vx-migrate 0.0.0 from turbo.json config dropped turbo() from .vx/workspace.ts ran npm uninstall @vzn/vx-migrate tasks 3 clean files packages/app/vx.config.ts packages/lib/vx.config.ts vx-preset.ts lock 2 project configs → .vx/projects.lock.json note Turbo passes every XDG_DATA_*, COREPACK_*, VSCODE_*, DOCKER_*, BUILDKIT_*, COMPOSE_*, JB_IDE_*, TURBO_*, VERCEL_*, NEXT_*, GITHUB_*, RUNNER_*, __NIXOS_*, NIX_* variable to each task; the configs pass its literal names only — add the ones a task reads to exec.env.passThrough
next vx run build --allpackages/lib/vx.config.ts, as written:
import type { ProjectConfig } from '@vzn/vx/config'import { globalEnvInputs, globalPassThroughEnv } from '../../vx-preset.js'
export default { tasks: { build: { exec: { command: 'mkdir -p dist && cp src/*.js dist/', env: { passThrough: [...globalEnvInputs, ...globalPassThroughEnv], }, }, dependsOn: ['^build'], inputs: { files: ['src/**', 'package.json'], env: [...globalEnvInputs], }, outputs: { files: ['dist/**'], }, cache: true, }, },} satisfies ProjectConfigThe lockfile plugin keys each package on its own dependencies and gives
^build its package edges.
Try it in five minutes
Section titled “Try it in five minutes”examples/turbo
is a Turbo workspace with that .vx/workspace.ts added. A test runs these
lines on every commit:
bun install && vx lock && git init && git add -A && git commit -m initvx run test --all # local 3↑: lib#build, app#build, app#testvx run test --all # local 3✓bunx @vzn/vx-migrate # tasks 3 cleangit add -A && git commit -m migratevx run test --all # local 3✓: the written configs derive the same keysecho "import { bun } from '@vzn/vx-lockfile'; export default { plugins: [bun()] }" > .vx/workspace.ts # drop turbo(); bun() keeps the edgesgit add -A && git commit -m donevx run test --all # local 3✓turbo.json, key by key
Section titled “turbo.json, key by key”The full mapping
Turborepo (turbo.json) |
vx (vx.config.ts) |
|---|---|
tasks / pipeline |
tasks |
dependsOn |
dependsOn, the same 'build', '^build', 'pkg#build' syntax |
inputs |
inputs.files; a glob that reaches a nested workspace package also in inputs.workspaceFiles |
outputs |
outputs.files, plus the .tsbuildinfo a tsc -b writes outside them |
env |
inputs.env and exec.env.passThrough |
passThroughEnv |
exec.env.passThrough |
cache: false |
no cache: true: the task always runs, keyed on its inputs |
persistent: true |
exec.persistent: { … } |
with |
dependsOn a persistent sidecar, started beside the task |
interactive: true |
exec.interactive: true: the task gets the terminal, alone |
interruptible |
nothing: vx watch keeps a persistent task up and restarts it only when its config changes or it dies |
tags |
nothing: labels Turbo keeps out of the hash and the behaviour |
experimentalCI |
nothing: Turbo reads it for external CI tooling and acts on nothing |
outputLogs (Turbo 1: outputMode) |
"new-only" is the default; other values are the run’s --output-logs |
dotEnv (Turbo 1), a .env input |
inputs.runtime: a probe that prints every .env file’s name and bytes, because a gitignored .env is invisible to a git glob (written configs name it from the preset: dotenvFiles, dotenvFilesDeep, and dotenvRootFiles for the root globs); a root one ($TURBO_ROOT$/.env, globalDotEnv) is inputs.workspaceRuntime, a probe of only the files its globs name |
the root package.json’s engines, which Turbo hashes into every task |
inputs.workspaceRuntime: a probe that prints that value alone (rootEngines in vx-preset.ts), so raising engines.node re-runs every task and no other root edit does |
an input or globalDependencies path git ignores (config.local.json) |
inputs.workspaceRuntime: a probe that prints the file’s name and bytes, since core refuses a file input git ignores; a gitignored file a glob matches is not keyed |
command (Turbo 2.11) |
exec.command (the argv, quoted); null or [] is no task |
description |
description |
extends |
nothing: a package task merges over the root’s, field by field |
$TURBO_ROOT$/file |
inputs.workspaceFiles / outputs.workspaceFiles |
globalDependencies / globalEnv / globalPassThroughEnv (and Turbo 1’s globalDotEnv) |
a generated vx-preset.ts each config imports as vx-preset.js (your tsc accepts it without allowImportingTsExtensions; Bun loads the .ts), except where a tsconfig*.json in the config’s directory or above it (below the root) is a composite project or sets rootDir and takes the config in: there the config declares the values itself, as tsc refuses a file outside the package (TS6059); a wildcard env name is reported, not mapped |
The command itself comes from your package.json script, with its
pre<name> / post<name> hooks added around it.
| Turborepo | vx |
|---|---|
turbo run build |
vx run build --all |
turbo run build --filter=@app/* |
vx run build --filter "@app/*" |
turbo run build --affected |
vx run build --affected (turbo() takes TURBO_SCM_BASE, or GitHub Actions’ base, as Turbo does; a TURBO_SCM_HEAD other than the checked-out commit is a warning) |
turbo run build --force, TURBO_FORCE=true |
vx run build --force; turbo() reads TURBO_FORCE too |
turbo run build --continue |
vx run build --continue (always, as Turbo’s; without it, vx skips only a failure’s dependents where Turbo stops) |
TURBO_TOKEN remote cache |
turboCache() reads the same variables |
Beside nx.json, init writes one vx.config.ts per project from
nx graph, so Nx must be installed. A Lerna 6+ workspace maps the same way.
bunx @vzn/vx init --keep writes only this .vx/workspace.ts. Then
bunx @vzn/vx-migrate writes the configs:
import type { WorkspaceConfig } from '@vzn/vx/config'import { nx } from '@vzn/vx-migrate'import { npm } from '@vzn/vx-lockfile'import { vxHistory } from '@vzn/vx-history'
export default { plugins: [ nx(), npm(), vxHistory(), ],} satisfies WorkspaceConfigbunx @vzn/vx init --keepbunx @vzn/vx-migrateThe first prints:
vx-migrate 0.0.0 from nx.json, run by nx() from @vzn/vx-migrate ran npm install -D @vzn/vx-lockfile @vzn/vx-history files .vx/workspace.ts .vx/.gitignore lock 0 project configs → .vx/projects.lock.json (and the tasks a plugin maps for app, lib) note a temporary start: vx init --native writes native config
next vx run build --allThe second prints:
vx-migrate 0.0.0 from nx graph config dropped nx() from .vx/workspace.ts ran npm uninstall @vzn/vx-migrate tasks 2 clean files packages/app/vx.config.ts packages/lib/vx.config.ts lock 2 project configs → .vx/projects.lock.json note migrating from the resolved project-graph snapshot — plugin-inferred targets are frozen as static config; `nx:run-commands` targets are their shell lines, and every other executor runs as itself through `nx-exec` (keep Nx and @vzn/vx-migrate installed until those targets are rewritten as commands); targets with `.env` files run through `nx-env`
next vx run build --allIts note says how targets map:
- a target an Nx plugin infers is written out as a plain task;
- an
nx:run-commandstarget becomes its shell line; - any other executor runs as itself through
nx-exec, so keep Nx and@vzn/vx-migrateinstalled until you rewrite it as a command; - a target with
.envfiles runs throughnx-env.
packages/lib/vx.config.ts, as written:
import type { ProjectConfig } from '@vzn/vx/config'
export default { tasks: { build: { exec: { command: 'mkdir -p dist && cp src/index.js dist/lib.js', }, inputs: { files: ['src/**/*'], }, outputs: { files: ['dist'], }, cache: true, }, },} satisfies ProjectConfigAn executor target is written as an nx-exec line: the executor runs
as itself through Nx’s runExecutor. Rewrite each one as the command it
wraps (jest, vite build, tsc -p …) to remove Nx:
test: { exec: { command: `nx-exec @nx/jest:jest --project a --target test --options '{"jestConfig":"libs/a/jest.config.ts"}'` } }| Nx | vx |
|---|---|
a project’s targets |
tasks |
dependsOn (^build, app:build) |
dependsOn (^build, app#build) |
configurations |
one task per configuration: build, build:ci |
inputs / namedInputs |
inputs.files |
{workspaceRoot}/file |
inputs.workspaceFiles |
{ "env": "VAR" } |
inputs.env and exec.env.passThrough |
{ "runtime": "<cmd>" } |
inputs.workspaceRuntime: it runs at the workspace root, as Nx’s does |
{ "json": "<file>", "fields": … } |
the whole file in files / workspaceFiles: a superset of the fields |
outputs |
outputs.files (or workspaceFiles for dist/<project>) |
nx build app |
vx run app#build |
nx run app:build:production |
vx run app#build:production |
nx affected -t test |
vx run test --affected (nx() takes NX_BASE or defaultBase as its base) |
nx graph |
vx run build --all --dry=json (tasks and their deps), and the graph explorer |
nx reset |
vx cache clean clears it all; --older-than <age> trims it; there is no daemon |
| Nx Cloud cache | nxCache() for a self-hosted Nx cache |
Generators, Nx Console and module-boundary rules have no vx equivalent; keep Nx for those.
Vite Task
Section titled “Vite Task”When the root package.json lists vite-plus, bunx @vzn/vx-migrate
writes a vx.config.ts per package. It reads each vite.config
run.tasks and the package’s scripts.
Add --from vite-task when turbo.json or Nx is there too.
Vite Task traces the files a task reads and writes; vx needs them
declared. A task with no cache.input and cache.output runs uncached
until you fill its TODO(vx-migrate).
The field-by-field mapping is in the @vzn/vx-migrate README.
Common problems
Section titled “Common problems”- A project is missing. A project is a directory holding a
vx.config.ts;bunx @vzn/vx-migratewrites one for every project and prints any directory to add to your workspace globs. - A task always runs. vx caches only a task with
cache: true.vx-migratefills it fromturbo.jsonor the Nx graph; a Turbo task withcache: falsehas none. - An env var is missing in the command. vx isolates the environment: list it in
exec.env.passThrough(Environment variables). vx run buildran one package. Without--all, vx runs the package you are in.