@vzn/vx-migrate 0.0.636 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,23 +2,24 @@
2
2
 
3
3
  Website: <https://vznjs.github.io/vx/> · this plugin: <https://vznjs.github.io/vx/plugins/vx-migrate/> · source: <https://github.com/vznjs/vx>
4
4
 
5
- Everything for adopting [`@vzn/vx`](https://vznjs.github.io/vx/) from Turborepo, Nx or Vite Task, in one package with zero dependencies:
5
+ Everything for adopting [`@vzn/vx`](https://vznjs.github.io/vx/) from Turborepo, Nx, Vite Task or plain `package.json` scripts, in one package with zero dependencies:
6
6
 
7
- - **`turbo()`** — a temporary start for a Turbo repository: the plugin fills vx's `project` stage from `turbo.json` and each package's `package.json` scripts until the migrator writes native config.
7
+ - **`turbo()`** — a temporary start for a Turbo repository: the plugin makes each package-manager member a project (`discover`; core finds only directories holding a `vx.config`) and fills vx's `project` stage from `turbo.json` and each package's `package.json` scripts until the migrator writes native config.
8
8
  - **`nx()`** — the same temporary start for an Nx repository, filled from Nx's resolved project graph. Executor targets (`@nx/js:tsc`, `@nx/vite:build`, your own) run as themselves through **`nx-exec`**, one executor per process.
9
- - **`bunx @vzn/vx-migrate`** — write one `vx.config.ts` per workspace package from your `turbo.json`, an exported Nx project graph, or vite-plus's `run.tasks` (Vite Task, `vp run`), plus the workspace file every run needs. Runs without a workspace file, so it is the first command, not the second.
9
+ - **`bunx @vzn/vx-migrate`** — write one `vx.config.ts` per workspace package from your `turbo.json`, an exported Nx project graph, vite-plus's `run.tasks` (Vite Task, `vp run`), or else each package's `package.json` scripts, plus the workspace file every run needs. Runs without a workspace file, so it is the first command, not the second; `vx init` runs it in any repo with a `package.json`.
10
10
  - **`turboCache()`** and **`nxCache()`** — keep the remote cache you have: any server speaking Turbo's `/v8/artifacts` API (Vercel's hosted cache included) or Nx's self-hosted `/v1/cache` spec.
11
11
 
12
12
  ```sh
13
13
  npm install -D @vzn/vx @vzn/vx-migrate # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
14
14
  ```
15
15
 
16
- In a Turbo or Nx repo, `npx vx init` writes the `vx.workspace.ts` that
17
- declares `turbo()` or `nx()` and prints the install-and-run line. Add
18
- `turboCache()` or `nxCache()` to keep your remote cache:
16
+ In a Turbo or Nx repo, `npx @vzn/vx init --keep` runs this package, which
17
+ writes the `.vx/workspace.ts` that declares `turbo()` or `nx()`, installs
18
+ what it imports and writes the lock. Where the repo shows a remote cache it
19
+ declares `turboCache()` or `nxCache()` too; by hand it reads:
19
20
 
20
21
  ```ts
21
- // vx.workspace.ts — a Turbo repo mid-migration, with its remote cache
22
+ // .vx/workspace.ts — a Turbo repo mid-migration, with its remote cache
22
23
  import { defineWorkspace } from '@vzn/vx/config'
23
24
  import { turbo, turboCache } from '@vzn/vx-migrate'
24
25
 
@@ -37,7 +38,7 @@ Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps eve
37
38
 
38
39
  ### Locking
39
40
 
40
- `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.
41
+ `vx lock` freezes the evaluation of written `vx.config.*` files only. A package that has none gets its tasks from `turbo.json` on every run, 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.
41
42
 
42
43
  ### What it does not do
43
44
 
@@ -57,16 +58,16 @@ Rules:
57
58
  - **Definition order**: root `pkg#name` if there is one, else root `name` (the first replaces the second whole, as Turbo looks it up), then each package config its own `turbo.json` extends (`"extends": ["//", "shared"]`, read by Turbo 2.11, refused by 2.5; nearest last, a parent missing or a cycle refused as Turbo refuses it) and its own, each winning field by field, except an overlay array holding `$TURBO_EXTENDS$` (Turbo 2.5+), which is the inherited list plus the overlay's other entries (`inputs: ["$TURBO_EXTENDS$", "config.json"]`).
58
59
  - **The command is the script body** with its `pre<name>` / `post<name>` hooks folded in, in that order, through core's `foldScriptHooks`: each part in its own subshell, the chain stopping at the first that fails, forwarded `--` args reaching the body alone (npm and pnpm run them around `<name>` without being asked; novu's `prebuild` copies the CSS its `build` inlines), one process less per task than `pnpm run <name>` — except a body that calls yarn's `run` builtin (`run -T rollup -c`, `run clean && run build`; yarn ≥ 2 runs scripts in its own shell), which is `run: command not found` in sh: it runs as `yarn run <name>`, as Turbo and Nx run it, and yarn ≥ 2 runs no hooks, so none are folded there. A Yarn Plug'n'Play workspace (`.yarnrc.yml` with no `nodeLinker`, or `pnp`) has no `node_modules`: a dependency resolves only through `.pnp.cjs` and a bin only through yarn's shims, so there every script runs as `yarn run <name>`, and a `nodeLinker` change maps afresh. Same rules for `nx:run-script` below (strapi and novu, 2026-09-11).
59
60
  - **Turbo 2.11's task `command`** (`futureFlags.experimentalTaskCommand`) wins over the script, as Turbo holds it: an argv is the command — each word quoted, run from the package dir, no `pre`/`post` hooks — and gives the package the task even with no script (turborepo's `@turbo/types#build`, `docs#schema`); `null` or `[]` is Turbo's no-op node, no task even where the script exists, its edges passed through; a per-toolchain map applies its `javascript` entry (or `typescript`, Turbo's alias), and without one the script runs. A shape Turbo would refuse keeps the script, with a todo.
60
- - `dependsOn`: `^x` passes through when some package runs `x` or turbo.json defines it, and is dropped otherwise (core refuses a `^x` no project declares as a typo); `pkg#task` is kept only when `pkg` emits `task` (else a todo: edge dropped); a same-package task Turbo defines but the package has no script for is Turbo's no-op node, so its own edges pass through in its place (`test → codegen → ^build` with no `codegen` script is `test → ^build`); another package's `^x` or `pkg#x` reaches such a node through a group task when its edges name a task of its own package or another (with-tailwind's `ui` has no `build` script; its `build` builds `build:styles` and `build:components`, and `web#build` waits on them), and so does one whose `^` edge names another task (rallly's script-less `build` holds `^db:generate`), while one whose only edge is `^` to its own name needs none, since core's `^x` walks past a package without `x`; a no-op node nothing else reaches is a group too in a package nothing but the root depends on, since no dependant's `^` edge runs its edges there (vercel/ai's `turbo run type-check` builds each example through its script-less `type-check: [build]`, and kitchen-sink's `turbo run test` builds `@repo/ui` for `admin`, which has no `test` script); elsewhere it is none, its edges run by its dependants'; a name Turbo does not define has no edge; `$TURBO_ROOT$` deps are a todo. A task turbo.json defines and no package has a script for (documenso's `lint`, shadcn-ui's `check`) is a group task in each package that defines it, its edges kept (a `pkg#task` edge only in `pkg`), so `vx run lint` runs Turbo's no-op nodes and exits 0 as `turbo run lint` does. Root `//#task`s run in the workspace root, as under Turbo: `turbo()` names the root a project through core's `discover` stage when turbo.json declares one (no `vx.config` needed; a root no member glob lists), and `//#x` in `dependsOn` is an edge to it, named by the root `package.json` `name`; a root task's globs are the workspace's (`workspaceFiles`), since Turbo hashes a root task over the whole repo; with no root project (a root `package.json` with no `name`, or one a package holds) they are a note and such an edge a todo. Turbo runs no plain task in the root package of a monorepo, and neither does `turbo()`; in a single-package repo (no workspaces, Turbo's `non-monorepo` example) turbo.json's plain tasks run on the root package, as under Turbo.
61
+ - `dependsOn`: `^x` passes through when some package runs `x` or turbo.json defines it, and is dropped otherwise (core refuses a `^x` no project declares as a typo); `pkg#task` is kept only when `pkg` emits `task` (else a todo: edge dropped); a same-package task Turbo defines but the package has no script for is Turbo's no-op node, so its own edges pass through in its place (`test → codegen → ^build` with no `codegen` script is `test → ^build`); another package's `^x` or `pkg#x` reaches such a node through a group task when its edges name a task of its own package or another (with-tailwind's `ui` has no `build` script; its `build` builds `build:styles` and `build:components`, and `web#build` waits on them), and so does one whose `^` edge names another task (rallly's script-less `build` holds `^db:generate`), while one whose only edge is `^` to its own name needs none, since core's `^x` walks past a package without `x`; a no-op node nothing else reaches is a group too in a package nothing but the root depends on, since no dependant's `^` edge runs its edges there (vercel/ai's `turbo run type-check` builds each example through its script-less `type-check: [build]`, and kitchen-sink's `turbo run test` builds `@repo/ui` for `admin`, which has no `test` script); elsewhere it is none, its edges run by its dependants'; a name Turbo does not define has no edge; `$TURBO_ROOT$` deps are a todo. A task turbo.json defines and no package has a script for (documenso's `lint`, shadcn-ui's `check`) is a group task in each package that defines it, its edges kept (a `pkg#task` edge only in `pkg`), so `vx run lint` runs Turbo's no-op nodes and exits 0 as `turbo run lint` does. Root `//#task`s run in the workspace root, as under Turbo: `turbo()` names the root a project through core's `discover` stage when turbo.json declares one (no `vx.config` needed), as it names every package the package manager lists, and `//#x` in `dependsOn` is an edge to it, named by the root `package.json` `name`; a root task's globs are the workspace's (`workspaceFiles`), since Turbo hashes a root task over the whole repo; with no root project (a root `package.json` with no `name`, or one a package holds) they are a note and such an edge a todo. Turbo runs no plain task in the root package of a monorepo, and neither does `turbo()`; in a single-package repo (no workspaces, Turbo's `non-monorepo` example) turbo.json's plain tasks run on the root package, as under Turbo.
61
62
  - A transit node (Turbo's documented pattern, its with-vitest example: `transit: { dependsOn: ["^transit"] }` with no script anywhere, and `test: { dependsOn: ["transit"] }`) is a key-only task in each package that defines it: a keyed group (no command, nothing spawns) keyed on its inputs, with its `^transit` edge, so a dependant's `test` re-keys on its dependencies' sources as Turbo re-hashes it. A `^` task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a `^` task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants. A `build` whose only edge is `^build` is not written: core gives every project with no `build` that node (solid's three script-less packages each got a `build` of their own). A dependency without the script whose own edges lack `^x` gets it too, so a `^x` stops there as Turbo's does (opencode's `build: { dependsOn: [] }`: core walked past to the builds below, which Turbo's `^build` never reaches).
62
63
  - `with` (tasks Turbo runs alongside, `web#dev` with `api#dev`): an edge to each sidecar that is persistent, so vx starts the task once the sidecar has spawned and runs the sidecar only when the task runs; a sidecar that ends would be waited for, so it is a todo instead, and a pair that names each other keeps one edge (two would be a cycle). A task with no script whose `with` names persistent sidecars (Turbo's with-tailwind example: `ui` has no `dev` script, its `dev` starts `dev:styles` and `dev:components`) is a group task that depends on them.
63
- - `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none where the producer is cached (vx folds each dependency's key); from a `cache: false` producer (vercel/vercel's `//#generate:cache-keys`, which records the host) the named files, or its outputs, are a `cache.inputs.workspaceRuntime` probe, and `cache.inputs.tasks` leaves the producer out, so core answers the probe after it ran. Then: absent or `[]` → `**/*` (Turbo's default); `$TURBO_DEFAULT$` → `**/*`; exclusions alone narrow `**/*`; `$TURBO_ROOT$/<path>` → `cache.inputs.workspaceFiles` (negation kept); any other `$TURBO_ROOT$` use is a todo. `globalDependencies` and Turbo 1's `globalDotEnv` land in `workspaceFiles` too, and a task's Turbo 1 `dotEnv` in its `files`. Turbo 1's `$NAME` entries, in `globalDependencies` or a task's `dependsOn`, are env vars: they join `cache.inputs.env` and `exec.env.passThrough`. A package nested in the task's package (cal.com's apps inside `@calcom/app-store`) is hashed by Turbo with its parent, and core's globs stop at a project: each glob that reaches one is also listed in `cache.inputs.workspaceFiles`. Turbo's glob grammar is translated as Nx's is (`*.[jt]s` is `*.{[jt],j,t}s`); an input with no safe form widens to `**/*` with a todo. A `.env`-shaped input (`.env*`, `.env.local`, a task's Turbo 1 `dotEnv`, create-turbo's `globalDependencies: ["**/.env.*local"]`) is gitignored as a rule, so it is keyed by a probe that hashes the `.env` files it can name (`cache.inputs.runtime`) or, for a root entry, the files its root-relative globs name, hidden ones too, as Turbo's `*` matches them (`workspaceRuntime`; `**/<name>` is a `find -name` that skips `node_modules`, and another `**` walks the workspace), rather than as a file glob git never reports. A package whose `.env` globs all sit at its root gets a one-shell probe of that directory; one below the root walks the package.
64
- - `outputs`: `$TURBO_ROOT$/<path>` → `cache.outputs.workspaceFiles`; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next's `.next/**` minus `!.next/cache/**` keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo's grammar (`dist/**/*.[cm]js`); the first stays a literal (a route directory). An output whose first segment is a wildcard (`**/*.d.ts`, `*/**`) runs the task uncached with a todo: Turbo never cleans an output, vx cleans it before every run, and such a glob reaches the sources. One segment with a literal extension the package tracks no file of (`*.xml`, `*.tsbuildinfo`) reaches only top-level artifacts and stays cached, and so does `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`; `node_modules` is never an output), and so does a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). A wildcard first segment with a literal rest (clerk's `*/package.json`, the subpath stubs it commits) reaches only files of that name: each committed one, up to 16, is taken back with `!` and the task stays cached; more, and it runs uncached. An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) runs the task uncached with a todo: vx would delete the manifest before every run, and core refuses that config. `nx()` takes the manifest back with `!package.json` instead and keeps the cache (owner, 2026-10-03). So does one that covers the package's vx.config; `turbo()` and `nx()` check the config the package has, and `vx-migrate` the one it writes, so sanity's `*.js` shims stay cached beside none. `nx()` keeps a wildcard-first output cached instead: each committed file it reaches is taken back with `!` (past sixteen, uncached with a todo), and the clean removes the rest, what the task writes (owner, 2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) is taken back with `!` — Turbo and Nx never clean an output, vx does — so it survives the clean and the task keeps its cache; past sixteen such files the task runs uncached with a todo. `turbo()` and `nx()` both do this, and so do the configs `vx-migrate` writes from turbo.json or an Nx graph (each take-back is a todo there, as it names files a later commit may move).
64
+ - `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none where the producer is cached (vx folds each dependency's key); from a `cache: false` producer (vercel/vercel's `//#generate:cache-keys`, which records the host) the named files, or its outputs, are a `cache.inputs.workspaceRuntime` probe, and `cache.inputs.tasks` leaves the producer out, so core answers the probe after it ran. Then: absent or `[]` → `**/*` (Turbo's default); `$TURBO_DEFAULT$` → `**/*`; exclusions alone narrow `**/*`; `$TURBO_ROOT$/<path>` → `cache.inputs.workspaceFiles` (negation kept); any other `$TURBO_ROOT$` use is a todo. `globalDependencies` and Turbo 1's `globalDotEnv` land in `workspaceFiles` too, and a task's Turbo 1 `dotEnv` in its `files`. Turbo 1's `$NAME` entries, in `globalDependencies` or a task's `dependsOn`, are env vars: they join `cache.inputs.env` and `exec.env.passThrough`. A package nested in the task's package (cal.com's apps inside `@calcom/app-store`) is hashed by Turbo with its parent, and core's globs stop at a project: each glob that reaches one is also listed in `cache.inputs.workspaceFiles`. Turbo's glob grammar is translated as Nx's is (`*.[jt]s` is `*.{[jt],j,t}s`); an input with no safe form widens to `**/*` with a todo. A wildcard `X/**` input, `!` or not, also lists `X`, as outputs do: Turbo's `**` matches zero segments, so `*/**` keys on the package-root files too. A `.env`-shaped input (`.env*`, `.env.local`, a task's Turbo 1 `dotEnv`, create-turbo's `globalDependencies: ["**/.env.*local"]`) is gitignored as a rule, so it is keyed by a probe that hashes the `.env` files it can name (`cache.inputs.runtime`) or, for a root entry, the files its root-relative globs name, hidden ones too, as Turbo's `*` matches them (`workspaceRuntime`; `**/<name>` is a `find -name` that skips `node_modules`, and another `**` walks the workspace), rather than as a file glob git never reports. A package whose `.env` globs all sit at its root gets a one-shell probe of that directory; one below the root walks the package.
65
+ - `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). A wildcard `X/**` also lists `X`: Turbo's `**` matches zero segments, so medusa's `*/**` caches the package-root `tsconfig.tsbuildinfo` too (its `package.json` is taken back, below); a literal `dist/**` needs no `dist`. An output whose first segment is a wildcard (`**/*.d.ts`, medusa's `*/**`) stays cached, as in `nx()` (`turbo()` 2026-10-14): Turbo never cleans an output, vx cleans it before every run and every restore, and such a glob can reach the sources; core never cleans a committed file, and every untracked file it reaches is deleted, which each run's warnings name (`output "*/**": … every untracked file this glob reaches is deleted`). Negations beside it (`!src/**`) keep what they name. Without git the files cannot be told apart, and the task runs uncached with a todo. A glob that reaches no tracked kind gets no warning: one segment with a literal extension the package tracks no file of (`*.xml`), `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`), and a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) takes the manifest back with `!package.json` and keeps the cache, in `turbo()` as in `nx()` (owner, 2026-10-03): vx would otherwise delete it before every run. The build rewrites a file it may list as an input (`@vzn/vx-lockfile` lists `package.json`), so the Turbo mapping also sets `inputStability: { strategy: 'mutable' }` and an identical rewrite counts as unchanged. So does one that covers the package's vx.config; `turbo()` and `nx()` check the config the package has, and `vx-migrate` the one it writes, so sanity's `*.js` shims stay cached beside none. `nx()` keeps a wildcard-first output cached the same way (owner, 2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) needs nothing: core never cleans a file git tracks, so it survives and the task keeps its cache.
65
66
  - `env` / `passThroughEnv`: explicit names go to `cache.inputs.env` (env only) and `exec.env.passThrough` (both, plus both globals); a wildcard expands over the run's environment under `turbo()`, which maps where the tasks run (a new variable name such a wildcard matches maps afresh); the CLI, which writes for another environment, lists the names the task's package and its workspace dependencies spell (a root task: the whole repo; clerk's `E2E_*`), noted once per entry, and one nothing spells is a todo; `*` is the only wildcard, as in Turbo, so `\*`, a leading `\!`, `?` and `[` are literal: unkey's `NEXT_PUBLIC_\*` names one variable, which vx cannot key, so it is dropped and reported once for the workspace with the count of tasks naming it (openstatus's root `build` env `\*` had written one todo into each of 45 configs; `turbo()`: only when the run's environment sets it, as Turbo hashes nothing otherwise); and a `!` entry (openstatus' `!NEXT_PUBLIC_VERCEL_URL`) removes the names it matches from its list, with no todo, since vx matches no name it is not given. A name both a global list and the task's own list carry is listed once. A name that is no shell variable name (`p-q`) is keyed but left out of `exec.env.passThrough`, as `sh` drops it before the task runs.
66
67
  - Framework inference (Turbo hashes and passes Next's `NEXT_PUBLIC_*`, Vite's `VITE_*`, … for a package that depends on the framework): `turbo()` expands the prefix over the run's environment; the CLI writes the names the tracked source and `.env.example` of the package and its workspace dependencies spell (the bare prefix, as a `startsWith` test spells it, is none) (Next bundles theirs: cal.com's web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.
67
68
 
68
- - **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — the task with a `^` edge keeps its cache (the first declared when none has one); the rest run uncached with a todo naming the keeper and the fix, their own output path. Same rule for Nx targets.
69
- - **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): vx's default `rules.upfrontKeys` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. An output that names one committed file (eslint's `eslint-suppressions.json` in a `lint` task's outputs) is dropped, and so is the `!` each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.
69
+ - **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. Same rule for Nx targets.
70
+ - **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): vx's default `rules.upfrontKeys` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. A keyed group (an `nx-input:<name>` twin, a transit node) runs nothing, so core lets it read what a task writes: it gets no `!` for a literal, the `!` that would hide a committed file goes, and it keeps its key (typescript-eslint's ast-spec commits the `*.shot` snapshots its `test` outputs, and its twin was written as `{}`, which core refuses). An output that names one committed file (eslint's `eslint-suppressions.json` in a `lint` task's outputs) is dropped, and so is the `!` each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.
70
71
  - **Two packages' tasks on one workspace output** (cal.com's shared `post-install` writes `../../node_modules/@prisma/client/**` from every package with the script): core refuses two cached tasks on one path with no edge between them, so `turbo()` (and `nx()`) keeps the first (in package and task order) cached and runs the rest uncached, each with a todo naming the keeper. A package's own outputs count at their workspace path: typescript-eslint's root project caches `dist` and each package's typecheck `dist/packages/<name>`, one path twice. Edges across packages are not read, so an ordered pair loses its cache too.
71
72
  - `cache: false` or `persistent: true` → no `cache` block; a persistent task gets `exec.persistent: {}` and, when some task depends on it, the consumer's `persistentTodo`.
72
73
  - A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
@@ -74,8 +75,8 @@ Rules:
74
75
  - `interruptible` maps to nothing: `vx watch` keeps a persistent task up across cycles and restarts it only when its config changes or it dies.
75
76
  - A task's `tags` (Turbo main, after 2.11.5) map to nothing: Turbo keeps them out of the hash and the run.
76
77
  - `envMode: "loose"` (top-level or in `global`, or `TURBO_ENV_MODE=loose`, which wins) is a note: Turbo hands every task the whole environment, vx only the declared names.
77
- - The workspace keys vx has a home for, top level or in `global`, fill what `vx.workspace.ts` leaves unset: `concurrency` (`"10"`, `"50%"` of the cores) → `concurrency`; `cacheMaxSize` / `cacheMaxAge` (`"0"` is off; weeks become days, a bare number days; a size in Turbo's grammar, `7.5GB` or bare bytes, restated whole: `7680MB`) → `cacheRetention.maxSize` / `.olderThan`. `TURBO_CONCURRENCY`, `TURBO_CACHE_MAX_SIZE` and `TURBO_CACHE_MAX_AGE` win over turbo.json, as under Turbo; a set one decides, its `0` included.
78
- - `TURBO_SCM_BASE`, the base `turbo run --affected` compares with, is `affectedBase` when `vx.workspace.ts` sets none: a bare `vx run build --affected` compares with the same ref. With it unset on GitHub Actions (`GITHUB_ACTIONS`), Turbo's own base applies: a pull request's `GITHUB_BASE_REF`, else the push event's `before` from `GITHUB_EVENT_PATH`.
78
+ - The workspace keys vx has a home for, top level or in `global`, fill what `.vx/workspace.ts` leaves unset: `run.concurrency` (`"10"`, `"50%"` of the cores) → `run.concurrency`; `cacheMaxSize` / `cacheMaxAge` (`"0"` is off; weeks become days, a bare number days; a size in Turbo's grammar, `7.5GB` or bare bytes, restated whole: `7680MB`) → `cache.retention.maxSize` / `.olderThan`. `TURBO_CONCURRENCY`, `TURBO_CACHE_MAX_SIZE` and `TURBO_CACHE_MAX_AGE` win over turbo.json, as under Turbo; a set one decides, its `0` included.
79
+ - `TURBO_SCM_BASE`, the base `turbo run --affected` compares with, is `run.affectedBase` when `.vx/workspace.ts` sets none: a bare `vx run build --affected` compares with the same ref. With it unset on GitHub Actions (`GITHUB_ACTIONS`), Turbo's own base applies: a pull request's `GITHUB_BASE_REF`, else the push event's `before` from `GITHUB_EVENT_PATH`.
79
80
  - Turbo hashes each package's `microfrontends.json` (or `.jsonc`) into every task, or the one file `VC_MICROFRONTENDS_CONFIG_FILE_NAME` names; so does `turbo()`.
80
81
  - Turbo 2.11's `global` block (`futureFlags.globalConfiguration`) is read as the `globalDependencies`, `globalEnv` and `globalPassThroughEnv` it replaces.
81
82
  - An unknown Turbo key is a todo naming it; `extends` is accepted and ignored (the overlay order above is what it means).
@@ -83,20 +84,24 @@ Rules:
83
84
  ## `nx()` — an Nx repo, mid-migration
84
85
 
85
86
  ```ts
86
- // vx.workspace.ts — an Nx repo, mid-migration
87
+ // .vx/workspace.ts — an Nx repo, mid-migration
87
88
  import { defineWorkspace } from '@vzn/vx/config'
88
89
  import { nx } from '@vzn/vx-migrate'
89
90
 
90
91
  export default defineWorkspace({ plugins: [nx()] })
91
92
  ```
92
93
 
93
- 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`.
94
+ 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. `nx()` is also the repo's edge source: each project's dependencies on Nx's graph (package deps, `implicitDependencies`, imports) are its `deps`, so `^build` follows Nx's order with no lockfile plugin. 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 the committed files it reaches kept (core never cleans one) and the rest cleaned before the run, 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`.
94
95
 
95
96
  | Option | Meaning |
96
97
  | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
97
98
  | `root` | Directory holding `nx.json`. Defaults to the workspace root. |
98
99
  | `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. |
99
100
 
101
+ ### Module boundaries
102
+
103
+ `@nx/enforce-module-boundaries`' `depConstraints` in the root ESLint config become `run.rules.dependencies`, which `vx lock` checks over each project's workspace dependencies, unless `.vx/workspace.ts` sets its own. `sourceTag` is `from`, `onlyDependOnLibsWithTags` is `allow`, `notDependOnLibsWithTags` is `deny`. The config is read, never run, so a flat config's constraints must be a literal array. vx leaves out what it cannot express: `allSourceTags`, a `/regex/` tag, and banned external imports. vx checks package dependencies, not source imports, and lets an untagged project depend on anything.
104
+
100
105
  ### `nx:run-commands`
101
106
 
102
107
  Nx's own option handling, rendered as one POSIX `sh` line (`vx show` prints it):
@@ -117,7 +122,7 @@ Nx's own option handling, rendered as one POSIX `sh` line (`vx show` prints it):
117
122
  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:
118
123
 
119
124
  ```ts
120
- import { defineWorkspace } from '@vzn/vx/config'
125
+ import { defineWorkspace, type Duration } from '@vzn/vx/config'
121
126
  import { nx, type NxExecutorTarget } from '@vzn/vx-migrate'
122
127
 
123
128
  export default defineWorkspace({
@@ -126,7 +131,7 @@ export default defineWorkspace({
126
131
  executors: {
127
132
  '@acme/tools:run': ({ options }: NxExecutorTarget) =>
128
133
  typeof options.command === 'string'
129
- ? { command: options.command, timeout: options.timeoutMs as number | undefined }
134
+ ? { command: options.command, timeout: options.timeout as Duration | undefined }
130
135
  : undefined,
131
136
  },
132
137
  }),
@@ -134,11 +139,11 @@ export default defineWorkspace({
134
139
  })
135
140
  ```
136
141
 
137
- 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.
142
+ 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`, a duration with its unit (`'10m'`), `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.
138
143
 
139
144
  ### `.env` files
140
145
 
141
- 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.
146
+ 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/projects.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.
142
147
 
143
148
  ### The target a task sees
144
149
 
@@ -158,15 +163,15 @@ Two cached tasks on one workspace path cannot both keep their cache, and the fir
158
163
 
159
164
  ### nx.json `parallel`
160
165
 
161
- `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.
166
+ `parallel` (or the legacy `tasksRunnerOptions.default.options.parallel`) is the run's `run.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.
162
167
 
163
168
  ### nx.json `defaultBase`
164
169
 
165
- `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.
170
+ `NX_BASE`, else `defaultBase` (Nx 19's `affected.defaultBase` before it), is `run.affectedBase` when `.vx/workspace.ts` sets none: a bare `vx run test --affected` compares with the ref `nx affected` does.
166
171
 
167
172
  ### nx.json `maxCacheSize`
168
173
 
169
- `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.
174
+ `maxCacheSize` (or `NX_MAX_CACHE_SIZE`, above it as in Nx) is the run's `cache.retention.maxSize` when `.vx/workspace.ts` sets no retention, in Nx's grammar (`10GB`, `1.5 GB`, bare bytes); `0` is no cap.
170
175
 
171
176
  ### Lerna
172
177
 
@@ -183,7 +188,7 @@ Once per run the plugin keys its snapshot (`<cache dir>/nx-project-graph.json`)
183
188
  ### What it does not do
184
189
 
185
190
  - A task the package's own `vx.config` already declares is left alone — the plugin fills, it never overwrites. Migrate a package by writing its config; the rest of the repo keeps running from the graph.
186
- - An Nx project no workspace package matches — the root project, or an integrated repo's `project.json` library no package glob lists (analogjs) — is made a project through core's `discover` stage, by its `package.json` name or else its Nx name, and its targets attach there by directory; no `vx.config` is needed. One whose name a package already holds, or whose directory is gone, has nowhere to go and is reported once per run; run its targets with `nx`. A `^target` only such a project declares is no edge, as under Nx, and an explicit edge to one of its targets is dropped with a todo (the key misses it) rather than refusing the run. A negated output (`!{projectRoot}/dist/cache`) takes its path back from the positive ones (core's A-44). A cached target with no `outputs` takes what Nx caches for it: `options.outputPath` (each path of a list), or for `build` and `prepare` `dist/{projectRoot}` and `{projectRoot}/dist`; Nx's `{projectRoot}/build` and `{projectRoot}/public` are outputs too when git tracks nothing under them; one that holds a tracked file is a todo, since vx cleans an output before the run.
191
+ - Each package-manager member is a project through core's `discover` stage (core finds only directories holding a `vx.config`), named by its `package.json`. An Nx project no workspace package matches — the root project, or an integrated repo's `project.json` library no package glob lists (analogjs) — is made a project the same way, by its `package.json` name or else its Nx name, and its targets attach there by directory; no `vx.config` is needed. One whose name a package already holds, or whose directory is gone, has nowhere to go and is reported once per run; run its targets with `nx`. A `^target` only such a project declares is no edge, as under Nx, and an explicit edge to one of its targets is dropped with a todo (the key misses it) rather than refusing the run. A negated output (`!{projectRoot}/dist/cache`) takes its path back from the positive ones (core's A-44). A cached target with no `outputs` takes what Nx caches for it: `options.outputPath` (each path of a list), or for `build` and `prepare` `dist/{projectRoot}` and `{projectRoot}/dist`; Nx's `{projectRoot}/build` and `{projectRoot}/public` are outputs too when git tracks nothing under them; one that holds a tracked file is a todo, since vx cleans an output before the run.
187
192
  - Nx adds an `nx-release-publish` target (`@nx/js:release-publish`) to every project with a `package.json`; it comes along as one `nx-exec` task per package, run only when asked (`vx run nx-release-publish --filter <pkg>`), and counts in `vx info`'s task total. A migration does not write it: Nx release's publish step skips a private package and a published version and rewrites `workspace:` ranges, which no one line does, so the report has one note naming the package manager's own `publish`.
188
193
  - `nx-exec` and `nx-env` must be on every task's PATH, which they are when `@vzn/vx-migrate` is a devDependency of the workspace (its bins land in `node_modules/.bin`); a plugin loaded by path warns once per run when they are not. Both load Nx from the workspace, so a repo run from an exported `graph` with no `node_modules/nx` warns once too.
189
194
  - A `dependsOn` entry's `params: "forward"` (the run's arguments) and `options: "forward"` (this target's options, its configuration's merged in) hand the dependency overrides, a different command than vx's one task per target: each is one todo per task, naming no dependency so a run's warnings group them (an atomized `e2e-ci` forwards to every spec), `options` only when there are options to forward.
@@ -208,33 +213,36 @@ Why the command carries the options: vx's key sees them (resolved-config hashing
208
213
  ## `bunx @vzn/vx-migrate` — write the configs
209
214
 
210
215
  ```bash
211
- bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json, nx.json or lerna.json, then vite-plus
216
+ bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json, nx.json or lerna.json, then vite-plus, else package.json scripts
212
217
  bunx @vzn/vx-migrate --dry # print the generated files + the report instead of writing
213
218
  bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
214
219
  bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
215
220
  bunx @vzn/vx-migrate --from vite-task # Vite Task beside turbo.json or Nx
221
+ bunx @vzn/vx-migrate --from scripts # package.json scripts beside any of them
216
222
  bunx @vzn/vx-migrate --native # write vx.config.ts files without asking
217
- bunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo() or nx(), as `vx init` writes it
223
+ bunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo() or nx(), with its plugins
218
224
  bunx @vzn/vx-migrate --help # the usage, exit 0
219
225
  ```
220
226
 
221
227
  It is the one command a repo needs (`pnpx @vzn/vx-migrate` in a pnpm repo works the same):
222
228
 
223
- - In a terminal it asks which adoption you want: **native** (the default, a `vx.config.ts` per package) or **keep** (the workspace file `vx init` writes, declaring `turbo()` or `nx()`). `--native` / `--keep` answer it; with no terminal (CI, a pipe) it is native.
224
- - With no `vx.workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the `@vzn/vx-lockfile` factory for the lockfile (`pnpm()`, `bun()`, `npm()`, `yarn()`), `scheduleHistoryPlugin()`, and `github()` when `.github/workflows` exists, from `@vzn/vx-ci`, each installed beside `@vzn/vx` at vx-migrate's own version. Keep adds them to the file `vx init` writes, or to one already in that shape; any other workspace file is the user's and left alone. Native over a file vx wrote (a keep adoption's) rewrites it: `turbo()` and `nx()` go, since the configs written replace them, and the other plugins stay.
229
+ - In a Turbo or Nx repo, in a terminal, it asks which adoption you want: **native** (the default, a `vx.config.ts` per package) or **keep** (a workspace file declaring `turbo()` or `nx()`). `--native` / `--keep` answer it; with no terminal (CI, a pipe) it is native. Elsewhere both are refused. A repo already set up (a workspace file declaring neither, and project configs) is said to be, exit 0, unless `--force`.
230
+ - Native or keep, a remote cache the repo shows is declared: `turboCache()` for turbo.json's enabled `remoteCache`, a `turbo link`ed `.turbo/config.json` or a CI file (`.github/workflows/*`, `.gitlab-ci.yml`, `.circleci/config.yml`) setting `TURBO_TOKEN`; `nxCache()` for one setting `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. The report names the file that showed it. Nx Cloud (`nxCloudId`, `nxCloudAccessToken`, the `nx-cloud` runner) is named instead: vx does not speak its wire.
231
+ - With no `.vx/workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the remote cache above, the `@vzn/vx-lockfile` factory for the lockfile (`pnpm()`, `bun()`, `npm()`, `yarn()`), `scheduleHistoryPlugin()`, and `github()` when `.github/workflows` exists, from `@vzn/vx-ci`, each installed beside `@vzn/vx` at vx-migrate's own version. Keep adds them to the file it writes, or to one already in that shape; any other workspace file is the user's and left alone. Native over a file vx wrote (a keep adoption's) rewrites it: `turbo()` and `nx()` go, since the configs written replace them, and the other plugins stay.
225
232
  - It installs what the written files import with the repo's own manager (`packageManager`, else the lockfile): `@vzn/vx`, the declared plugins' packages, and `@vzn/vx-migrate` for keep or when a written task runs its `nx-exec` or `nx-env` bin (`pnpm add -D -w`, `yarn add -D -W` on Yarn 1 and without `-W` on Yarn 2+, `bun add -d`, `npm install -D`). A package the root both lists and has installed at vx-migrate's own version is left alone; any other is installed at that version. Native removes a listed `@vzn/vx-migrate` when nothing written needs it (no `nx-exec` / `nx-env` task, no `turboCache()` or `nxCache()` left in the workspace file): `pnpm remove -w`, `yarn remove` (`-W` on Yarn 1), `bun remove`, `npm uninstall`. `--no-install` skips both.
233
+ - Native, once the files are written, it ends with `vx lock`: `.vx/projects.lock.json` is written and the graph rules are checked, in one line before `next:`. A refusal is printed and the configs stay written (exit 0). `--no-install` skips it, since the plugins the workspace file declares are not installed yet, and `next:` then starts with `vx lock`.
226
234
 
227
- 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.
235
+ 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()`. 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 map their scripts. Beside turbo.json, Turbo runs the tasks and Lerna only publishes: turbo.json is the source.
228
236
 
229
237
  Your `package.json` scripts are never edited. `--dry` installs nothing and says what it would install.
230
238
 
231
239
  `--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).
232
240
 
233
- `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.
241
+ Every source hands 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.
234
242
 
235
243
  ### Turbo
236
244
 
237
- 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 config that a `tsconfig*.json` in its directory or above (below the root) takes in, where that project is composite or sets `rootDir`, declares the values it uses instead: `tsc` refuses a file outside the package (TS6059, TS6307; withastro/astro's `scripts/` and the `performance/` project its `packages/astro/tsconfig.test.json` takes in). 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.
245
+ 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 config that a `tsconfig*.json` in its directory or above (below the root) takes in, where that project is composite or sets `rootDir`, declares the values it uses instead: `tsc` refuses a file outside the package (TS6059, TS6307; withastro/astro's `scripts/` and the `performance/` project its `packages/astro/tsconfig.test.json` takes in). 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.
238
246
 
239
247
  | Turborepo | vx |
240
248
  | ----------------------------------- | ------------------------------------------------------------------------------------------------- |
@@ -247,7 +255,7 @@ Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package
247
255
 
248
256
  ### Nx
249
257
 
250
- 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`](#nxrun-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.
258
+ 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`](#nxrun-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()` a keep adoption 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`), so the package manager installs it and a lockfile plugin names it; 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 (`run.concurrency`, `run.affectedBase`, `cache.retention.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.
251
259
 
252
260
  ### Vite Task
253
261
 
@@ -267,11 +275,257 @@ Detected when the root `package.json` lists `vite-plus`, after turbo.json and Nx
267
275
  | a `package.json` script | a task, uncached; with root `run.cache.scripts` a TODO for its cache |
268
276
  | `run.enablePrePostScripts` (default `true`) | a script's `pre<name>` / `post<name>` folded into its command; `false` keeps them tasks of their own |
269
277
 
278
+ ### package.json scripts
279
+
280
+ Where no runner's config is the source (or under `--from scripts`), each
281
+ package's `package.json` scripts become its `vx.config.ts`, plus a
282
+ `.vx/workspace.ts` that declares the `@vzn/vx-lockfile` plugin for the lockfile at
283
+ the root (`pnpm()`, `bun()`, `npm()` or `yarn()`; `{ plugins: [] }`
284
+ without one): package edges (`^build`, `--affected`) come from it.
285
+ Running and caching here are the floor, so it declares no executor or
286
+ cache, and the file carries no comment (it is the user's). When
287
+ `@vzn/vx-lockfile` is not installed, it installs it with that manager,
288
+ pinned to its own version, before `vx lock` runs (`--dry` names the
289
+ command instead). A workspace file already there is left as it is. A workspace with no scripts at
290
+ all still gets the workspace file, a printed example config, and the
291
+ next command to run. A root
292
+ `package.json` with no `workspaces` field is single-project mode, and
293
+ when `packages/*/package.json` files sit below it unreached, both
294
+ the mapper and a run that finds no config say so instead ("package.json
295
+ declares no workspaces … Add "workspaces": ["packages/*"] to
296
+ package.json and re-run") rather than "no scripts" or "run vx init". A
297
+ workspace whose globs match no `package.json` gets a note naming them
298
+ (M-42); a nameless member, or two sharing a name, is matched and
299
+ named on its own line instead (M-46). When such skipped scripts are all there
300
+ is, the report opens `no package.json scripts became tasks.` rather than
301
+ saying there are none (M-59). When `pnpm-workspace.yaml` lists the
302
+ members and the root `package.json`'s `workspaces` lists other globs,
303
+ the note names the unread list (M-51): pnpm reads only the yaml, while
304
+ bun, npm and yarn read `package.json`. With
305
+ no `package.json` or `pnpm-workspace.yaml` here or above, it stops and
306
+ names `vx init`, which sets up a workspace without one. Every
307
+ generated config is typed for the editor through
308
+ `import type { ProjectConfig } from '@vzn/vx/config'` and `satisfies
309
+ ProjectConfig` — a type-only import Bun erases, so the file loads in a
310
+ workspace that runs the `vx` binary without the package installed. The
311
+ workspace file takes the same form (`satisfies WorkspaceConfig`). A
312
+ runtime `import { defineWorkspace } from '@vzn/vx'` costs nothing extra
313
+ since 2026-09-10: the running `vx` serves its own core to that
314
+ specifier (bin.ts registers the alias), so a config or a plugin package
315
+ never loads a second copy, and a workspace that runs the binary needs
316
+ no `@vzn/vx` installed for it — the type-only form is still what the
317
+ scaffold writes, because it types the same with no import to resolve
318
+ in an editor without the package. A workspace that imports a PLUGIN
319
+ package at runtime without having installed it is told so, and to
320
+ install its dependencies. `--mjs` writes the same objects as `vx.config.mjs` and
321
+ `.vx/workspace.mjs`, with no type import and no `satisfies`: a package whose
322
+ own `tsconfig` includes every `.ts` under it compiles a `vx.config.ts`
323
+ into its dist (TanStack/query's `tsc --build`, 2026-09-11), and an
324
+ `.mjs` is outside that include.
325
+ A file it writes that `.gitignore` drops (vueuse ignores
326
+ `packages/**/*.mjs`) gets a `note:` naming it and the `!vx.config.mjs`
327
+ line that keeps it, since a commit would leave it behind unnoticed.
328
+ When a whole directory is ignored, such as the old layout's `/.vx/`
329
+ cache line now hiding `.vx/workspace.ts` and the lock, the note names
330
+ that directory instead: git re-includes nothing inside an ignored
331
+ directory, so no `!` line helps. vx never edits `.gitignore`.
332
+ A package with a `tsconfig.json` and no `"type": "module"` gets
333
+ `vx.config.mts`: tsc (node16, nodenext) reads a `.ts` there as
334
+ CommonJS, and its type import of the ESM `@vzn/vx/config` fails
335
+ (TS1479, TS1541: medusa's plugin builds, 2026-10-14); an output that
336
+ takes the config back names the `.mts`. The files written
337
+ then go through the repo's own formatter when its root installs one
338
+ (`prettier --write`, `biome format --write`, `oxfmt` or `dprint fmt`,
339
+ the first found), and a `note:` says so: create-t3-turbo's `prettier
340
+ --check` failed on the quotes of a written config. A formatter that
341
+ fails leaves the files as written and the note names its error.
342
+
343
+ Each script becomes a task with its command verbatim. `build` gets
344
+ `dependsOn: ['^build']` (a `build` that only delegates, `pnpm run
345
+ compile`, is a group, and the edge goes on the script that does the
346
+ work) and **no cache block** — under a
347
+ `TODO(vx-migrate)` showing the block to add with the package's real
348
+ inputs and outputs, and the same block commented as the task's first
349
+ line: set the real globs and delete the `// `. A task without a cache block always runs; a block
350
+ with EMPTY outputs is not "uncached" but a no-output task that hits on
351
+ unchanged inputs and skips the build with nothing to restore (a deleted
352
+ `dist` stays deleted under a green `up-to-date` run — what `vx init`
353
+ generated until 2026-09-04), and a guessed `dist/**` would restore the
354
+ wrong tree for every package that writes elsewhere. The block the TODO
355
+ shows names `dist/**`, or the default output of the framework the command
356
+ runs: `.next/**` minus `!.next/cache/**` for `next build`, `.output/**`
357
+ for Nuxt, `build/**` for Remix, React Router, Create React App and
358
+ Docusaurus, `public/**` for Gatsby, `storybook-static/**` for Storybook;
359
+ for any other command, the directory it names with `--outDir` / `--out-dir`
360
+ / `-d`, else the ones it cleans first (`del-cli distribution`, `rimraf lib
361
+ types`), unless it makes one again with `mkdir` (D-90).
362
+ A package in a cycle of builds (nuxt's `@nuxt/nitro-server` devDepends
363
+ on `nuxt`, which depends on it; pnpm sorts it away) gets, instead of
364
+ `^build`, an edge to each build outside its cycle that `^build` would
365
+ reach, and a TODO to order the cycle: `^build` there refuses the run.
366
+ A script that runs a workspace package's bin the package gets only
367
+ through the root's dependencies (vite's playgrounds run `vite` from the
368
+ root's `vite: workspace:*`) also waits on that package's build
369
+ (`vite#build`): no `^build` edge reaches it.
370
+ `test` / `typecheck` wait for
371
+ `build` when the package has one (`lint` reads sources and gets no
372
+ edge); `dev` / `start` / `serve` / `watch` /
373
+ `preview` become persistent tasks, with a TODO to add `readyWhen` when
374
+ another task depends on one, and so does a watcher: a `watch` segment in the script's name (`build:watch`),
375
+ a `--watch` flag, `tsc -w` / `rollup -w`, or nodemon (D-40), and a server:
376
+ `serve <dir>`, `http-server`, bare `vite`, a tool's `dev` / `serve` /
377
+ `start` / `preview` verb (`next start`, `netlify dev`), or a script that
378
+ runs such a script of its package by name (`cross-env X=1 pnpm start`) or
379
+ through a runner (`run-p web api`, `concurrently "npm:web" "npm:api"`, D-113),
380
+ outside quotes and not sent to the background with `&` (D-91), read past
381
+ a launcher's `--package <name>` / `-p <name>` (`pnpm dlx --package
382
+ netlify-cli netlify dev`, D-112). A
383
+ script whose name no task may carry (`lint#fix`, `^up`; the schema's
384
+ rule, item 1000) is left out with a TODO rather than written into a
385
+ config every later command refuses, and a `__proto__` script is written
386
+ as a computed key, since a literal `__proto__:` sets the prototype. An
387
+ existing `.vx/workspace.*` in any extension the loader reads (`.mts`
388
+ included) is kept, and `--force` REPLACES a package's config of another
389
+ extension (`replaced:` in the report) rather than writing a second one
390
+ the loader would choose between by its order (item 1033). Without
391
+ `--force`, it never overwrites: a package that already has a vx
392
+ config keeps it, untouched, the rest get theirs, and the report lists
393
+ the kept ones under `kept`, so a half-adopted workspace adopts the rest
394
+ (M-52). A Turbo, Nx or Vite Task migration still refuses an existing config.
395
+
396
+ Two npm conventions are mapped rather than copied, because copying them
397
+ loses behaviour. `pre<x>` / `post<x>` hooks, which npm runs around `x`
398
+ without being named, are folded into `x`'s command in that order, each
399
+ in its own subshell, so the chain stops at the first that fails whatever
400
+ a part holds (a `;`, an `exit`), and forwarded `--` args reach `x` alone,
401
+ as npm hands them to the script and not its hooks (item 905), appended
402
+ as text, so no part sees them as `$1` (X-60). The
403
+ command is a small shell function, `vx_script`, around the three parts;
404
+ it carries a TODO saying so; a `pre<x>` with no `x` stays a task of its own, and
405
+ npm's lifecycle hooks (`prepack`, `prepublishOnly`, …) are never tasks.
406
+ Every project gets a `vx.config.ts` (owner, 2026-10-14): a member with
407
+ no script to run (none, or only lifecycle hooks: a types-only or config
408
+ package), or with none a Turbo or Nx repo runs, gets one declaring
409
+ `tasks: {}`. A project that already has a config keeps it. A package with no `build` script whose `prepack`, `prepublishOnly`,
410
+ `prepublish` or `prepare` runs a builder (`bob build`, `tsc`, `tsup`, …)
411
+ is named in the report, which says to add a `build` script running it
412
+ (react-navigation's twelve packages, whose root `build` is `lerna run
413
+ prepack`, mapped with no build at all).
414
+ Where the package's manager runs no such hooks every `pre<x>` and
415
+ `post<x>` is a task of its own: Yarn 2+ (the nearest `packageManager:
416
+ yarn@2+`, or a Berry `yarn.lock`, D-31; a `packageManager` naming none of npm,
417
+ pnpm, yarn or bun defers to the lockfile, D-96), npm under `ignore-scripts=true`
418
+ in the `.npmrc` beside its lockfile, pnpm under
419
+ `enable-pre-post-scripts=false` there or `enablePrePostScripts: false` in
420
+ `pnpm-workspace.yaml` (D-33). Bun and Yarn 1 run them whatever those say.
421
+ Under Yarn 2+ a segment's `run <script>`, Yarn's shell builtin, is written
422
+ `yarn run <script>`: vx's shell has no `run` (D-92).
423
+ A Yarn 2+ repo on Plug'n'Play (no `nodeLinker: node-modules` or `pnpm` in
424
+ `.yarnrc.yml`) keeps its bins in `.pnp.cjs`, where vx's PATH finds none;
425
+ the report says so and names `nodeLinker: node-modules` or `yarn exec
426
+ '<command>'` (D-108).
427
+ A script reading `$npm_package_version`, `$npm_package_name` or
428
+ `$npm_lifecycle_event`, which every manager sets and vx does not, gets
429
+ them under `exec.env.define`, the first two read from an imported
430
+ `package.json` so a version bump reaches them; any other `$npm_*` it
431
+ reads gets a TODO (D-34). Among several packages, a workspace root
432
+ script that runs the members (`pnpm -r build`, `--filter`, `-C`, pnpm's `pn` alias
433
+ included (D-97), Yarn's
434
+ `--cwd` and `yarn workspace <name>`, npm's `--prefix`, npm's
435
+ and Yarn's workspace flags, a `cd` into or above a member, turbo, nx, lerna, `vp run`, vx itself) is not mapped
436
+ (a flag counts on the package manager, or after `node <bin> run`, and not on the program it
437
+ runs: berry's `yarn node -r ./setup.ts` is node's `--require`, D-81; bun's
438
+ `cd test && …` enters no member, D-83),
439
+ nor is one that runs such a script by name (vite's `ci-docs`: `pnpm build &&
440
+ pnpm docs-build`; through `run-s` / `run-p` / `npm-run-all` or `concurrently
441
+ "npm:x"` too, lexical's `ci-check`, D-95), and neither is one whose name a member's task carries, so `--all` never runs
442
+ a check twice (D-45). The rest check the whole repo (`lint: oxlint .`,
443
+ `test: vitest`) and become the root's own tasks in a root vx.config, when
444
+ the root has a `"name"` (vx skips a nameless root's config) and no config
445
+ of its own; one already there, hand-written or from an earlier
446
+ `vx init`, stays as written, `--force` included, and is listed under
447
+ `kept`. The report names each script left out and why
448
+ (a `pre` / `post` hook goes with its script, D-85), its examples of running
449
+ the members spelled by the repo's manager (`--workspaces` under npm, `yarn
450
+ workspaces foreach` under Yarn 2+), and says which;
451
+ with nothing mapped it names the root whenever it has a script, a member
452
+ or not (pnpm's root is not), and why: its scripts run the workspace, or
453
+ share a member's task name (listed), or both; it tells a root with no `"name"` to add one
454
+ first (vuejs/core), naming the scripts that would then map (react, D-87), and a root whose
455
+ `"name"` a member also carries to rename it, since a root config would make the
456
+ workspace refuse every run for the duplicate (insomnia, D-129). A single-package repo's root is its project and maps.
457
+ A lone root beside a `lerna.json` (Lerna-classic: packages listed there, not
458
+ in `workspaces`) gets a note naming the `workspaces` globs to add, Lerna's
459
+ `packages/*` default when it lists none (D-111).
460
+ A member whose package.json has no `name` is no project; one with a
461
+ script is named in the report, to be given a name (remix's
462
+ `packages/component/bench`, D-106).
463
+ A member's script that runs another member's work (`pnpm -C ../pinia
464
+ run build`, `yarn workspace <name>`, `-r` / `--filter`, a `cd` or `-C`
465
+ / `--cwd` / `--prefix` into another member) keeps its command and gets a
466
+ TODO naming that part: the graph runs that member's task once, so name it
467
+ under `dependsOn` and drop it from the command (pinia). A directory inside
468
+ the member or the root is no other member.
469
+ A script that is nothing but `npm run <other>` (`pnpm <other>`, `yarn
470
+ <other>`, `bun run <other>`, `npm test`, `npm start`) becomes a **group**
471
+ over `<other>` — `dependsOn` and no command — so the graph runs and
472
+ caches the target instead of a package-manager subprocess it cannot
473
+ see. When `build` is such a group, the task it reaches that runs a
474
+ command takes `^build` and the cache TODO, since a group has nothing
475
+ to cache; it and the groups on the way drop the wait on `build` a
476
+ `typecheck` or `test` would carry, since they are the build (D-16). Bare, a package manager's own command is not a script: `bun test`
477
+ is Bun's test runner, `bun build` its bundler, `pnpm install` and `yarn
478
+ add` the managers' verbs, so each stays a command (item 908), and so do
479
+ the names each manager reserves or hands on: `pnpm docs` and `pnpm
480
+ version` (npm's), `bun deploy`, `yarn check` (D-32); `pnpm test`, `yarn
481
+ test` and `bun lint` do run the script. Arguments, flags or a `&&`
482
+ chain make it a real command again and
483
+ it is left verbatim, and so is one whose target becomes no task (a
484
+ lifecycle script, or a hook folded into another script): a group over
485
+ it would name a task nothing defines (D-12). A verbatim chain whose
486
+ parts run this package's own scripts (`check: pnpm run build && pnpm run
487
+ lint`) gets a TODO naming those parts: each ran again inside the command,
488
+ beside its own task, so `vx run check` built twice (M-41). So does a
489
+ part that runs them through `run-s`, `run-p` or `npm-run-all`
490
+ (`build: run-p build:*`), unless the task is persistent (M-48). Its
491
+ order may matter, so it is not made a group.
492
+
493
+ The report lists each TODO once per reason: tasks that share one are
494
+ named together (the first five, then a count; the files carry each),
495
+ and when no task caches it says uncommenting a TODO task's cache line
496
+ makes the second run a hit. Its `next:` line is a command the user can type: the
497
+ runner that started vx (`npx`, `pnpm`, `yarn`, `bunx`, read from
498
+ `npm_config_user_agent`) with the installed `vx` bin, else the
499
+ `@vzn/vx` package (`npx` under Yarn 1, which has no `dlx`); with no
500
+ runner, a bare `vx`. Outside a git work tree it starts with
501
+ `git init`, and with no script mapped with declaring a task, since the
502
+ run would refuse without either. It runs `build`, else the first task that
503
+ neither serves (`persistent`) nor changes the repo (`clean`, `release`,
504
+ `publish`, `deploy`, `version`, `format`, `fix`, …), else the first task
505
+ (react-navigation was told `vx run clean --all`).
506
+
507
+ ### Errors reading the members
508
+
509
+ The root a migration starts from and its members are the package manager's (`src/packages.ts`): the nearest `pnpm-workspace.yaml`, or `package.json` with `workspaces`, that lists the directory it runs in. What it refuses:
510
+
511
+ | Symptom | Cause |
512
+ | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
513
+ | `failed to parse <file>: <why>` | A `package.json` / `pnpm-workspace.yaml` is not valid. |
514
+ | `<file>: packages must be an array of glob strings` | `pnpm-workspace.yaml` `packages:` is a bare string, etc. |
515
+ | `<file>: must be a JSON object` | A `package.json` (the root's or a member's) is `null`, a list or a scalar; it crashed with a TypeError until item 988. |
516
+ | `<file>: "name" must be a string with no surrounding whitespace` | A `package.json` `name` is a number, an object, or has surrounding whitespace (npm refuses one too); `{"name":123}` planned `123#build` until item 988. |
517
+ | `<file>: "name" cannot hold "#" — vx addresses a task as <name>#<task>` | A `package.json` `name` holds `#` (npm refuses one too): `{"name":"a#b"}` planned `a#b#build` under `--all`, but `vx run a#b#build` and a `dependsOn` split at the first `#` and found nothing. |
518
+ | `<file>: must be a mapping (packages: and pnpm's settings)` | `pnpm-workspace.yaml` is a list or a scalar. A mapping with no `packages:`, or an empty one (pnpm 10 settings or catalogs in a single-package repo) is not an error: the root's `package.json` decides, as without the file (item 984). |
519
+ | `<file>: workspaces must be an array of glob strings` | `package.json` `workspaces` holds a non-string entry. |
520
+ | `workspace member <dir> (<abs>) is outside the workspace root <root>: vx keeps every project under the root` | A member glob (`../ext/*`, an absolute path) reached a package outside the root. npm and pnpm take one; `--affected` asks git from the root and missed edits there. Move the root up to a directory that holds every member. |
521
+ | `<file>: workspaces.packages must be an array of glob strings` | The yarn-legacy `workspaces: { packages: [...] }` form holds a non-string entry. |
522
+ | `<file>: <field> entry "<glob>" is an extglob, which vx's glob engine does not read` | A member glob holds `!(…)`, `@(…)`, `+(…)`, `*(…)` or `?(…)`. `Bun.Glob` has no extglob (its scan widened `packages/!(x)` to include x, turborepo#3766); a whole-segment `!(a\|b)` gets its exact rewrite, `["packages/*", "!packages/a", "!packages/b"]`. |
523
+
270
524
  ## `turboCache()` — a Turbo remote cache
271
525
 
272
526
  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.
273
527
 
274
- 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:
528
+ 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:
275
529
 
276
530
  ```ts
277
531
  import { defineWorkspace } from '@vzn/vx/config'
@@ -290,6 +544,8 @@ export default defineWorkspace({
290
544
  })
291
545
  ```
292
546
 
547
+ Off CI a run reads the remote and does not write it unless its policy names the remote. Give the write token only to the default branch's CI and a read-only one to laptops and pull request jobs: the server's token is the boundary (`docs/security.md` § Cache poisoning). The same holds for `nxCache()`.
548
+
293
549
  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.
294
550
 
295
551
  | Option | Environment variable | Meaning |
@@ -334,18 +590,14 @@ The Nx spec has no existence probe, so `has` (the `--dry` prediction; the prefet
334
590
 
335
591
  ## Remote-cache behaviour (both)
336
592
 
337
- - 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.
338
- - The same failure is said once per run: an unreachable server fails the probe, the download and the upload alike, and the run prints the first and, at its end, `vx/turbo-cache: 2 more requests failed the same way: Unable to connect. Is the computer able to access the url?` (core's `LayeredCache` does the counting, for every cache plugin).
593
+ - A remote error degrades to a **miss** and a warning that names the request, the artifact and the server — `[vx] plugin '@vzn/vx-migrate': 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.
594
+ - 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] plugin '@vzn/vx-migrate': 2 more requests failed the same way: Unable to connect. Is the computer able to access the url?` (core does the counting, for every cache plugin).
339
595
  - A request answered `429` or `5xx` (not `501`), or one that never connected (a refused port, an unresolved host), is sent again after 2 s — a `429` after its `Retry-After`, capped at 10 s — as Turbo's client does. A spent deadline is not: it has already cost its wait.
340
596
  - Three outages in a row (no connection, no answer within the deadline, or a `5xx` once the resends are spent) open Turbo's outage breaker: no request is sent for 30 s, each lookup is a miss at once, then one request probes and its answer closes or reopens it. A hit is judged when its body ends, so a server that sends headers and then stalls the body counts the same way. A hung server costs three deadlines, not one per task.
341
597
  - A refused token (`401`/`403`) warns **once** and turns the layer off for the rest of the process (a `403` on an upload is a read-only token — Nx's spec, or turborepo-remote-cache's `READ_ONLY` and write-less JWTs: it turns off uploads alone, and reads go on) — including the requests already in flight when the refusal lands, which degrade in silence rather than repeating it (a six-project run printed five identical lines before 2026-09-20).
342
- - Policy (`--cache=remote:r`, …) is enforced by core's `LayeredCache`, which the plugins wrap — a read-only token pairs naturally with `remote:r`.
343
- - Each tool's own switches narrow that policy, never widen it, as they do for the tool: `TURBO_CACHE` (`--cache`'s syntax, an omitted source off) and `TURBO_REMOTE_CACHE_READ_ONLY` for `turboCache()`, `NX_SKIP_REMOTE_CACHE` / `NX_DISABLE_REMOTE_CACHE` (`true`) for `nxCache()`. A CI that keeps untrusted pull requests off the shared cache that way keeps vx off it too.
598
+ - Policy (`--cache-policy=remote:r`, `VX_CACHE_POLICY`, `cache.policy`) is enforced by core, which layers each plugin's remote over the local cache — a read-only token pairs naturally with `remote:r`.
599
+ - Each tool's own switches narrow that policy, never widen it, as they do for the tool: `TURBO_CACHE` (`--cache-policy`'s syntax, an omitted source off) and `TURBO_REMOTE_CACHE_READ_ONLY` for `turboCache()`, `NX_SKIP_REMOTE_CACHE` / `NX_DISABLE_REMOTE_CACHE` (`true`) for `nxCache()`. A CI that keeps untrusted pull requests off the shared cache that way keeps vx off it too.
344
600
 
345
601
  ## Testing
346
602
 
347
603
  `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).
348
-
349
- ## History
350
-
351
- `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.