Skip to content
vxvx
GitHubBlueskydev.toRSS

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.

  1. Run init at the workspace root

    Terminal window
    bunx @vzn/vx init

    Beside turbo.json or nx.json, it runs @vzn/vx-migrate, which writes one vx.config.ts per package, .vx/workspace.ts and the lock.

    It never overwrites a file without --force.

  2. Review the TODOs

    Search for TODO(vx-migrate). Each names a choice the migrator could not make for you.

  3. Run it

    Terminal window
    vx run build --all

    The 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.

  4. Delete the old config

    Once vx does what turbo run build or nx run-many -t build did, delete turbo.json or nx.json: the configs declare every task it mapped.

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)

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.

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 WorkspaceConfig
Terminal window
bunx @vzn/vx init --keep
Terminal window
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 --all

Where 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:

Terminal window
bunx @vzn/vx-migrate
Terminal window
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 --all

packages/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 ProjectConfig

The lockfile plugin keys each package on its own dependencies and gives ^build its package edges.

examples/turbo is a Turbo workspace with that .vx/workspace.ts added. A test runs these lines on every commit:

Terminal window
bun install && vx lock && git init && git add -A && git commit -m init
vx run test --all # local 3↑: lib#build, app#build, app#test
vx run test --all # local 3✓
bunx @vzn/vx-migrate # tasks 3 clean
git add -A && git commit -m migrate
vx run test --all # local 3✓: the written configs derive the same keys
echo "import { bun } from '@vzn/vx-lockfile'; export default { plugins: [bun()] }" > .vx/workspace.ts # drop turbo(); bun() keeps the edges
git add -A && git commit -m done
vx run test --all # local 3✓
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 WorkspaceConfig
Terminal window
bunx @vzn/vx init --keep
bunx @vzn/vx-migrate

The first prints:

Terminal window
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 --all

The second prints:

Terminal window
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 --all

Its note says how targets map:

  • a target an Nx plugin infers is written out as a plain task;
  • an nx:run-commands target becomes its shell line;
  • any other executor runs as itself through nx-exec, so keep Nx and @vzn/vx-migrate installed until you rewrite it as a command;
  • a target with .env files runs through nx-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 ProjectConfig

An 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.

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.

  • A project is missing. A project is a directory holding a vx.config.ts; bunx @vzn/vx-migrate writes 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-migrate fills it from turbo.json or the Nx graph; a Turbo task with cache: false has none.
  • An env var is missing in the command. vx isolates the environment: list it in exec.env.passThrough (Environment variables).
  • vx run build ran one package. Without --all, vx runs the package you are in.
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.