vx is shaped like Turborepo on purpose. Same per-package model, same
dependsOn micro-syntax ('build', '^build', 'pkg#build'), same
--filter DSL, same --affected. The migration is easy because
almost nothing has to change in how you think about the graph; what
changes is where the config lives and what it can say.
Step zero: a temporary start
turbo() from @vzn/vx-migrate fills vx's project stage from your existing
turbo.json and each package's scripts. It is a bridge while you
migrate, not a way to keep turbo.json: vx is fast on native config, and
only native config is what its benchmarks measure. vx init writes it
beside turbo.json:
npm install -D @vzn/vx # or pnpm / yarn / bun
npx vx init # writes vx.workspace.ts, below, and prints next:
npm install -D @vzn/vx-migrate && npx vx run build --all # the next: line
import type { WorkspaceConfig } from '@vzn/vx/config'
import { turbo } from '@vzn/vx-migrate'
export default { plugins: [turbo()] } satisfies WorkspaceConfig
Whatever the mapping cannot express becomes a warning on every run,
which is the same list bunx @vzn/vx-migrate --dry prints once. A
package that writes its own vx.config.ts keeps it; the plugin fills
and never overwrites. So you can migrate one package at a time, until
every package has its own config and vx.workspace.ts drops turbo().
Step one: let the tool write the files
bunx @vzn/vx-migrate --dry # preview the generated files and a report
bunx @vzn/vx-migrate # write them; never overwrites without --force
@vzn/vx-migrate is its own package so it runs before any vx file
exists, and it is the whole adoption in one command: in a terminal it
asks native (the default) or keep (--keep, the turbo() file
vx init writes), writes vx.workspace.ts with the plugins the repo
calls for, and installs what the files import. It reads the root pipeline and any per-package extends,
inlines the matching package.json script as the task's command, and
emits one vx.config.ts per package. A task goes where the script
exists, and to a package without it that another package's ^ task
reaches: a cached true with no outputs, Turbo's no-op node, so an edit
there still re-keys its dependants. Anything it cannot infer becomes a TODO(vx-migrate)
comment, never a silently wrong value. It renders from the same mapper
turbo() runs, so the files say exactly what the plugin was
already doing.
What maps, and what is better
turbo.json |
vx.config.ts |
|---|---|
tasks / pipeline
|
tasks |
dependsOn |
dependsOn, identical syntax |
inputs / outputs
|
cache.inputs.files / cache.outputs.files
|
env |
cache.inputs.env and exec.env.passThrough
|
passThroughEnv |
exec.env.passThrough |
cache: false |
omit the cache block |
persistent: true |
exec.persistent: {}, with a TODO to set readyWhen when a task depends on it |
with |
dependsOn a persistent sidecar, started beside the task |
interactive: true |
exec.interactive: true: the task gets the terminal, alone |
extends |
a package task merges over the root's; false alone opts out, false + keys runs on those 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 |
outputLogs (Turbo 1: outputMode) |
no per-task knob: the per-run --output-logs flag |
$TURBO_ROOT$/file |
cache.inputs.workspaceFiles |
dotEnv (Turbo 1) |
cache.inputs.runtime: a probe that hashes the .env files |
command (Turbo 2.11) |
the task's exec.command; null is no task |
description |
the task's description
|
globalDependencies, globalEnv, globalPassThroughEnv, globalDotEnv
|
a generated vx-preset.ts you import and spread |
Those are every key the mapper knows. Any other key in a task becomes
a TODO naming it, so nothing is dropped silently.
Three things you get that the JSON could not give you:
-
The command is in the config. Turborepo runs the script with the
task's name; vx makes
exec.commandexplicit. A task is one shell command, and you can read it where it is declared. -
Inputs are required and explicit. The migration writes Turbo's
default, every file in the package, as
**/*, where you can see and narrow it; the sandbox can then prove them. -
Presets are imports.
globalDependenciesbecomes a constant in a file every config imports, and the resolved-config hash sees it. No list to keep in sync.
The deliberate divergences
- A bare task name never widens an anchored task's scope. In Turbo,
turbo run web#lint buildalso runsweb#build; in vx,buildtakes the filter scope andweb#lintstays anchored. - No
--parallel. It exists in Turbo as an escape hatch for over-declared edges.dependsOnin vx is explicit;--concurrency 1serialises, and--exclude-dependencies[=<names>]drops edges. - Failure propagation starts one notch further along. Turbo stops the
run at the first failure; a vx run with no flag is
deps-ok— a task runs when its own dependencies succeeded, and only its dependents are skipped.--continue=neveris Turbo's default behaviour, and bare--continueisalways, which is what bare--continuemeans in Turbo too.
Every other Turbo behaviour a user would reach for is pinned by a
parity case that runs vx's real CLI against the Turbo contract it
stands in for. The full guide, with before/after
configs, is Migrate from Turborepo.
Originally published on the vx blog. vx is an MIT task runner and build cache for JS monorepos: vznjs.github.io/vx · GitHub.
Written with AI assistance.
Top comments (0)