@vzn/vx-migrate 0.0.0 → 0.0.484

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vx contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,306 @@
1
+ # @vzn/vx-migrate
2
+
3
+ Everything for adopting [`@vzn/vx`](https://github.com/vznjs/vx) from Turborepo or Nx, in one package with zero dependencies:
4
+
5
+ - **`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.
6
+ - **`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.
7
+ - **`bunx @vzn/vx-migrate`** — write one `vx.config.ts` per workspace package from your `turbo.json`, or an exported Nx project graph, plus the workspace file every run needs. Runs without a workspace file, so it is the first command, not the second.
8
+ - **`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.
9
+
10
+ ```sh
11
+ npm install -D @vzn/vx @vzn/vx-migrate # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
12
+ ```
13
+
14
+ In a Turbo or Nx repo, `npx vx init` writes the `vx.workspace.ts` that
15
+ declares `turbo()` or `nx()` and prints the install-and-run line. Add
16
+ `turboCache()` or `nxCache()` to keep your remote cache:
17
+
18
+ ```ts
19
+ // vx.workspace.ts — a Turbo repo mid-migration, with its remote cache
20
+ import { defineWorkspace } from '@vzn/vx/config'
21
+ import { turbo, turboCache } from '@vzn/vx-migrate'
22
+
23
+ export default defineWorkspace({ plugins: [turboCache(), turbo()] })
24
+ ```
25
+
26
+ The package exports `turbo`, `nx`, `turboCache`, `nxCache` and their options types (`TurboPluginOptions`, `NxPluginOptions`, `TurboCacheOptions`, `NxCacheOptions`); the mappers and cache clients are internal.
27
+
28
+ ## `turbo()` — a Turbo repo, mid-migration
29
+
30
+ Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every package's `build` script the way `turbo run build` would: `dependsOn` edges (`^build`, same-package deps, `pkg#task`), `inputs` / `outputs` as the cache block, `env` / `passThroughEnv`, `cache: false`, `persistent`. Turbo's global fields (`globalDependencies`, `globalEnv`, `globalPassThroughEnv`, Turbo 1's `globalDotEnv`) are inlined into every task; a `*` env name (`NEXT_PUBLIC_*`) expands over the run's environment, as Turbo matches it; `*` is Turbo's only wildcard (`\*`, a leading `\!`, `?` and `[` are literal), and a literal vx cannot key (unkey's `NEXT_PUBLIC_\*`) is left out, reported once only when the run's environment sets it; per-package `turbo.json` overlays apply. A `turbo.jsonc` (Turbo 2.5+) is read wherever a `turbo.json` would be, at the root and in a package; a workspace root with neither is refused, naming the fix. Turbo 2's framework inference (a package on `next` has `NEXT_PUBLIC_*` and `NEXT_DEPLOYMENT_ID` hashed and passed; Turbo's whole table, `expo`, `nuxt`, `remix`, `@sveltejs/kit` and the rest, each package taking the first framework it matches, as Turbo does, and leaving out names under the platform's `TURBO_CI_VENDOR_ENV_KEY`) applies too: the prefix expands over the run's environment into that package's tasks, and a task's `!` entry takes names back (`bunx @vzn/vx-migrate`, which writes files for another environment, names each framework and its packages in a note instead). What runs is what a migration would have written, minus the file: the key a task derives here equals the key the written config would derive.
31
+
32
+ | Option | Meaning |
33
+ | ------ | --------------------------------------------------------------- |
34
+ | `root` | Directory holding `turbo.json`. Defaults to the workspace root. |
35
+
36
+ ### Locking
37
+
38
+ `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.
39
+
40
+ ### What it does not do
41
+
42
+ - 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 `turbo.json`.
43
+ - The mapping's gaps are the migration's gaps, reported as warnings on every run instead of `TODO(vx-migrate)` comments: `$TURBO_ROOT$` tasks, an env name vx cannot key that the run's environment sets (`FOO_?`, which Turbo reads literally), unknown turbo keys. A persistent task's readiness note (`readyWhen`) is reported only when something depends on it — with no dependent there is nothing to gate (refine printed it for 375 `dev` targets a run). `bunx @vzn/vx-migrate --dry` lists the same set once. Each gap is one line per run for every task that carries it, not one per task (n8n marks `dev` and `watch` persistent in most of its 84 packages; astro negates `vendor/**` in the outputs of 57 tasks). A per-package `{ "extends": false }` with nothing else is Turbo's opt-out and maps to no task; with keys, the task runs on those keys alone. A glob that climbs out of the package (`../../packages/app-store/*.generated.ts`) is re-anchored on the workspace root as a `workspaceFiles` glob. A negated output under a literal-rooted one (`dist/**` minus `!dist/**/*.map`) takes its paths back (see `outputs` below); one against a wildcard-rooted output (medusa's `*/**` minus `!src/**`) makes the task uncached, because the clean before exec would otherwise delete the sources — declare the exact outputs in a vx.config to cache it.
44
+ - The cost is the stage's: on a 1,000-package workspace with no `vx.config` files the mapping loads in about 42 ms where 1,000 evaluated configs load in 22.
45
+ - The mapping is kept in `<cache dir>/vx-migrate-turbo-mapping.json`, keyed on every `turbo.json` / `turbo.jsonc`, every package manifest, the mapper's own code and the environment variable names a wildcard can match (one in a Turbo config or Turbo's framework table) or core cannot key, so an unrelated variable keeps it; a run with none changed reads it instead of mapping (astro, 562 packages: ~40 ms of mapping against ~12 ms of key reads).
46
+ - The mapping is read once per run, never once per process: under `vx watch`, a `package.json` script edit or a per-package `turbo.json` overlay edit is the next cycle's tasks. The root `turbo.json` is no task's input and lives in no project dir, so an edit to it is not a cycle — restart the watch.
47
+
48
+ ### The mapper (`mapTurboWorkspace`)
49
+
50
+ The plugin and the CLI read a repo the same way: the mapper the plugin runs live is what `bunx @vzn/vx-migrate` writes `vx.config.ts` per package from, splicing Turbo's global fields in as imports of a generated `vx-preset.ts`, where the plugin inlines the values. `splice` is the seam between the two consumers; `uses` names which globals a task drew on. Before 2026-09-10 the mapper lived in `@vzn/vx` itself; until 2026-09-11 the plugin was its own package, `@vzn/vx-turbo`.
51
+
52
+ Rules:
53
+
54
+ - **A task exists for a package only when the package declares the script** — Turbo's own rule. An absent script is silent; a script key whose value cannot be a command (a number, `null`, an empty string, an array) produces a `task: null` entry whose todo says why, rather than a config that fails to load.
55
+ - **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"]`).
56
+ - **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).
57
+ - **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.
58
+ - `dependsOn`: `^x` passes through when some package runs `x`, and is dropped when none does (Turbo gives it no edges; 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 runs nothing, its own edges included, where Turbo still runs them (kitchen-sink's `turbo run test` builds `@repo/ui` for `admin`, which has no `test` script; `vx run test --all` does not, and `vx run build test --all` does both); a name Turbo does not define has no edge; `$TURBO_ROOT$` deps are a todo. 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.
59
+ - 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: it runs `true`, cached and 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.
60
+ - `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.
61
+ - `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none (vx folds each dependency's key). 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.
62
+ - `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).
63
+ - `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.
64
+ - 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.
65
+
66
+ - **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. The mapping resolves it before the file is written — the task with a `^` edge keeps its cache (the first declared when none has one); a task a same-project edge orders after it stays cached too (vx caches what an ordered dependant ADDS to the tree — twenty's `build:individual` into `build`'s `dist`); the rest run uncached with a todo naming the keeper and the fix, their own output path or that edge. Same rule for Nx targets.
67
+ - **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.
68
+ - `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`.
69
+ - A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
70
+ - `outputLogs: "new-only"` maps to nothing: frames for the tasks that ran and a one-liner per cache hit is vx's default flow already. The other values are per-run in vx, so they are a todo naming the flag (`vx run … --output-logs hash-only`).
71
+ - `interruptible` maps to nothing: `vx watch` stops and re-spawns every persistent task each cycle.
72
+ - A task's `tags` (Turbo main, after 2.11.5) map to nothing: Turbo keeps them out of the hash and the run.
73
+ - `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.
74
+ - 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.
75
+ - `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`.
76
+ - 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()`.
77
+ - Turbo 2.11's `global` block (`futureFlags.globalConfiguration`) is read as the `globalDependencies`, `globalEnv` and `globalPassThroughEnv` it replaces.
78
+ - An unknown Turbo key is a todo naming it; `extends` is accepted and ignored (the overlay order above is what it means).
79
+
80
+ ## `nx()` — an Nx repo, mid-migration
81
+
82
+ ```ts
83
+ // vx.workspace.ts — an Nx repo, mid-migration
84
+ import { defineWorkspace } from '@vzn/vx/config'
85
+ import { nx } from '@vzn/vx-migrate'
86
+
87
+ export default defineWorkspace({ plugins: [nx()] })
88
+ ```
89
+
90
+ 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, `{ 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 — `true`, cached, 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. The twins show in a run's output; each costs about 0.16 ms of a warm run. A path input inside an output one of the project's own targets declares (TanStack/table's `public` input lists `{projectRoot}/dist`) is generated: git does not list it, so vx cannot key on it, and it is dropped with a todo; the task that writes it keys its dependants through `dependsOn`.
91
+
92
+ | Option | Meaning |
93
+ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
94
+ | `root` | Directory holding `nx.json`. Defaults to the workspace root. |
95
+ | `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. |
96
+
97
+ ### `nx:run-commands`
98
+
99
+ Nx's own option handling, rendered as one POSIX `sh` line (`vx show` prints it):
100
+
101
+ - **Where:** the workspace root unless `cwd` says otherwise — a `cd` from the project dir, since vx has no per-task cwd — with `{projectRoot}`, `{projectName}` and `{workspaceRoot}` expanded.
102
+ - **How:** each command runs in a shell of its own. `commands` run in **parallel** unless `parallel: false` (Nx's default): all start at once, the first failure TERMs the rest and fails the task once they have exited (exit 1, as Nx), and the task succeeds once every command has (nx#28477). With `parallel: false` they run in order and the first failure stops the rest. `commands: []` is a no-op that succeeds (nx#31345).
103
+ - **Arguments:** every option run-commands does not consume is appended to each command as `--name=value`, then `args`, then whatever follows `vx run … --` — per command unless `forwardAllArgs` is false (nx#12165). `{args}` takes them in place; `{args.name}` is the value passed after `vx run … --` under that name, else the target's options and `args`, spliced in as text as Nx splices it.
104
+ - **An argument replaces its option:** an option the arguments after `vx run … --` name (`--region=us`, `--region us`, `--no-region`, `-r` for a one-letter option; no camel-case expansion) is not appended, as Nx drops it; the line decides once it has them. `tests/nx-run-commands-override-live.test.ts` holds it to `nx run`.
105
+ - **Environment:** `env` is `exec.env.define` (so it is in the key), and `color: true` sets `FORCE_COLOR=true`, as Nx does (nx#20465). `envFile` is loaded after the task's `.env` files (below), a name they already set winning, as in Nx (nx#23581).
106
+ - **`readyWhen`** makes the task persistent with that string as its `readyWhen`, so dependents start once it is printed; several strings, all of which Nx waits for, run the line under `nx-env --ready-when` (one flag per string), which passes the output through and prints `nx-env: ready` once every string has appeared on stdout or stderr; that line is the task's `readyWhen`.
107
+ - **Reported, not reproduced:** per-command `prefix` / `color` decoration, `streamOutput: false`. Decoration in a serial run (`parallel: false`), which Nx refuses, is the failing placeholder with Nx's reason, as `readyWhen` there is. Display-only options (`usePty`, `tty`, `verbose`) change nothing here: vx runs every task without a pseudo-terminal.
108
+
109
+ `tests/nx-exec-live.test.ts` holds each of these shapes to Nx's own run-commands executor on the same options and arguments.
110
+
111
+ ### Your own executors
112
+
113
+ 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:
114
+
115
+ ```ts
116
+ import { defineWorkspace } from '@vzn/vx/config'
117
+ import { nx, type NxExecutorTarget } from '@vzn/vx-migrate'
118
+
119
+ export default defineWorkspace({
120
+ plugins: [
121
+ nx({
122
+ executors: {
123
+ '@acme/tools:run': ({ options }: NxExecutorTarget) =>
124
+ typeof options.command === 'string'
125
+ ? { command: options.command, timeout: options.timeoutMs as number | undefined }
126
+ : undefined,
127
+ },
128
+ }),
129
+ ],
130
+ })
131
+ ```
132
+
133
+ 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.
134
+
135
+ ### `.env` files
136
+
137
+ 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.
138
+
139
+ ### The target a task sees
140
+
141
+ Every task gets `NX_TASK_TARGET_PROJECT`, `NX_TASK_TARGET_TARGET`, `LERNA_PACKAGE_NAME` (the project, which Lerna on Nx's runner documents to scripts) and, where it runs a configuration, `NX_TASK_TARGET_CONFIGURATION` in `exec.env.define`, as Nx hands them to every task; a run-commands `env` wins. A package script's `nx exec -- <cmd>` reads them: without them it starts Nx's own task runner, which runs the target and its dependencies again.
142
+
143
+ ### Inputs that key nothing here
144
+
145
+ `{ dependentTasksOutputFiles }` hashes the outputs of the tasks a target depends on; vx folds those tasks' keys through `dependsOn`, and an output follows from its inputs. `{ workingDirectory }` hashes the directory Nx was started from; a vx task runs in its project dir from wherever vx is started. Neither is a todo.
146
+
147
+ ### The environment a task sees
148
+
149
+ Nx hands every task its whole environment; vx hands a task only its essentials (`PATH`, `HOME`, `CI`, `NODE_OPTIONS`, …), the names its Nx `{ env }` inputs declare (which also key it), and what `nx()` defines (the target, `.env` files, a run-commands `env`). A variable set in the shell and read by no declared input, `NODE_ENV` or `DATABASE_URL`, does not reach the task: declare it as an `{ env }` input of the target in the Nx config.
150
+
151
+ ### Atomized targets
152
+
153
+ Two cached tasks on one workspace path cannot both keep their cache, and the first declared is kept. Cypress's atomizer gives `e2e` the whole `videos` dir and each `e2e-ci--<spec>` a subdir of it, so `nx()` also tries the split target (a `nonAtomizedTarget`) last and keeps the order that caches more tasks: each spec's CI task keeps its cache and `e2e` runs uncached, with the todo. Where the specs share the split target's one path (jest's coverage dir) the declared order stands.
154
+
155
+ ### nx.json `parallel`
156
+
157
+ `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.
158
+
159
+ ### nx.json `defaultBase`
160
+
161
+ `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.
162
+
163
+ ### nx.json `maxCacheSize`
164
+
165
+ `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.
166
+
167
+ ### The graph snapshot
168
+
169
+ Once per run the plugin keys its snapshot (`<cache dir>/nx-project-graph.json`) on what Nx computes the graph from: `nx.json` and the files its `extends` chain names by content, and the worktree as git sees it — `HEAD`, `git status -uall` (core's own for the run, `DiscoverContext.worktreeChanges`: the tree is walked once, which took ~96 ms per run on refine; a `root` elsewhere asks git itself), and the content of every listed path under a project root (the last graph's too, so a project `discover` names counts before it is one), any `project.json`, or at the root (manifests, lockfiles, `tsconfig*.json`). A different key, or no snapshot, runs `node_modules/.bin/nx graph --file=<snapshot>` — the one time Nx itself runs, served from the daemon when one is up. So a source edit that adds a cross-package `import` (an edge Nx derives) or a config an Nx plugin infers targets from (`vite.config.ts`) re-exports before the run, tracked or untracked, and a touch without an edit, or a stray file a task writes at the root, does not. Outside a git worktree the plugin falls back to the manifests' mtimes. The snapshot and its key live in the cache dir, which ignores itself (a `*` `.gitignore` inside it), so writing them moves nothing. Nx's own caches (`.nx/cache/`, `.nx/workspace-data/`, which the export writes) never enter the key: under a root project (a standalone repo) that does not ignore them, each export re-exported on the next run. An export that fails with a snapshot in hand warns and runs on the previous graph. The snapshot is machine-local, like the cache: nothing about it enters a key. Measured at 1,000 projects: the export runs once (1.5 s with the daemon off after an edit, 0.9 s with it on; Nx's own computation), and a warm run with nothing changed pays the key, 43 ms at min where the manifest stats it replaced took 9 (item 1075), plus the mapping. Under `vx watch` the same rule runs per cycle: a `project.json` edit is the next cycle's tasks, the export included — 1.3 s from the edit to the new command's effect on that workspace. The mapping itself is kept beside it (`<cache dir>/vx-migrate-nx-mapping.json`), keyed on everything it reads — the graph, `nx.json` and its `extends` chain, every package manifest, each project dir's `.env` names, `NX_LOAD_DOT_ENV_FILES`, which bins are installed and the mapper's own code — so a warm run with nothing changed maps nothing.
170
+
171
+ ### What it does not do
172
+
173
+ - 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.
174
+ - 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.
175
+ - 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`.
176
+ - `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.
177
+ - 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.
178
+ - Batch executors run one task per process; an executor that reads `context.taskGraph` under `NX_BUILDABLE_LIBRARIES_TASK_GRAPH` sees none and takes Nx's project-graph path.
179
+ - The mapping's gaps are the migration's gaps, reported as warnings once per run for all the tasks that carry each one. `bunx @vzn/vx-migrate --dry --from nx` lists the same set once.
180
+
181
+ ## `nx-exec` — one Nx executor, one process
182
+
183
+ ```
184
+ nx-exec <executor> --project <name> --target <name> [--configuration <name>] [--options '<json>'] [--dotenv <file>]... [overrides…]
185
+ nx-env [--dotenv <file>]... [--envFile <file>] [--ready-when <string>]... -- <command> [args…]
186
+ ```
187
+
188
+ `nx-exec` is a bin this package installs, and what `nx()` runs for every executor target. It runs under the workspace's Node (executors are Node programs), resolves `nx` from the working directory up to the workspace's `node_modules`, reads Nx's cached project graph for the `ExecutorContext` executors expect (project root, dependencies for buildable libraries; computed in-process when the cache is missing or lacks the project, as `nx run` computes it, never through the daemon), **replaces** that project's target in the in-memory graph with the executor and options given, and calls Nx's own public `runExecutor`, so Nx's option merging, schema defaults and validation run unchanged. The executor runs from the workspace root, where Nx forks one, though vx starts the task in its project dir: an executor that resolves against `process.cwd()` (@nx/js's ts transformers, prettier's config) sees what it sees under Nx. Anything else on the line — what `vx run <target> -- --otp=123` appends — is an override, parsed by Nx's own `createOverrides` and handed to the executor as `nx run <p>:<t> --otp=123` would (nx#12165). `--dotenv` files are loaded into its environment first (see `.env` files above). The exit code is the outcome `nx run` reports: the executor's returned result when it returns one, else its last yielded result; a server executor keeps the process alive for as long as it yields. A thrown error (an executor's own, or a missing executor package) exits 1 with its message, and its stack only under `NX_VERBOSE_LOGGING=true`, as `nx run` reports one; an executor package that is not installed is one line naming it. The bin enables Node's on-disk compile cache for its own process (Node ≥ 22.1, a no-op below), which takes about 30 ms off every executed task after the first.
189
+
190
+ `nx-exec` sets `NX_DAEMON=false` unless set (it never dials the daemon), and `NX_VERBOSE_LOGGING=true` is the context's `isVerbose`.
191
+
192
+ `nx-env` loads the `.env` files and a run-commands `envFile` with Nx's own functions, then runs the command with `sh -c`, whatever follows it appended as vx appends forwarded arguments; its exit is the shell's. With `--ready-when`, the command's stdout and stderr pass through unchanged and `nx-env: ready` is printed once every string has been seen (one split across two writes included).
193
+
194
+ Why the command carries the options: vx's key sees them (resolved-config hashing holds), no ambient state decides what runs, `vx show` prints the truth and the line pastes into a shell. Each executed `nx-exec` line still loads Nx's own module graph (about 220 ms, on a miss only): it is a bridge, and the bare command that replaces it in native config pays none of that. The design is in `docs/design/nx-unchanged-2026-09.md`.
195
+
196
+ ## `bunx @vzn/vx-migrate` — write the configs
197
+
198
+ ```bash
199
+ bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json or nx.json
200
+ bunx @vzn/vx-migrate --dry # print the generated files + the report instead of writing
201
+ bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
202
+ bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
203
+ bunx @vzn/vx-migrate --help # the usage, exit 0
204
+ ```
205
+
206
+ `--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).
207
+
208
+ `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.
209
+
210
+ ### Turbo
211
+
212
+ 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. 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. The report names the `@vzn/vx-lockfile` plugin for the repo's lockfile when `vx.workspace.ts` declares none: Turbo keys each package on its own lockfile entries, and core keys every task on the whole file until a plugin claims it.
213
+
214
+ | Turborepo | vx |
215
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------- |
216
+ | `dependsOn` | `dependsOn`, same micro-syntax (`$TURBO_ROOT$` deps are a TODO) |
217
+ | `inputs` | `cache.inputs.files` (`$TURBO_DEFAULT$` → `**/*`; `$TURBO_ROOT$/x` → `workspaceFiles`) |
218
+ | `outputs` | `cache.outputs.files`, a negated one included |
219
+ | `env` | `cache.inputs.env` **and** `exec.env.passThrough` (child envs are isolated) |
220
+ | `passThroughEnv` | `exec.env.passThrough` |
221
+ | `cache: false` / `persistent: true` | no `cache` block; `exec.persistent: {}`, with a TODO to set `readyWhen` when a task depends on it |
222
+
223
+ ### Nx
224
+
225
+ 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), 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`). 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 note naming the `vx.workspace.ts` field to add (`concurrency`, `affectedBase`, `cacheRetention.maxSize`): the workspace file is written without them, and from nx.json alone, never the environment. 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.
226
+
227
+ ## `turboCache()` — a Turbo remote cache
228
+
229
+ 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.
230
+
231
+ 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:
232
+
233
+ ```ts
234
+ import { defineWorkspace } from '@vzn/vx/config'
235
+ import { turboCache } from '@vzn/vx-migrate'
236
+
237
+ export default defineWorkspace({
238
+ plugins: [
239
+ turboCache({
240
+ apiUrl: 'https://cache.example.com',
241
+ token: process.env.CACHE_TOKEN,
242
+ teamSlug: 'acme',
243
+ // Optional: sign uploads and verify downloads (Turbo's artifact signature).
244
+ // signatureKey: process.env.CACHE_SIGNATURE_KEY, teamId: 'team_acme',
245
+ }),
246
+ ],
247
+ })
248
+ ```
249
+
250
+ 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.
251
+
252
+ | Option | Environment variable | Meaning |
253
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
254
+ | `apiUrl` | `TURBO_API` | base URL of the cache server; default with a token: `https://vercel.com/api`; a `user:pass@` in it is refused |
255
+ | `token` | `TURBO_TOKEN` | Bearer token on every request |
256
+ | `teamId` | `TURBO_TEAMID` | `teamId` query parameter; required with `signatureKey` |
257
+ | `teamSlug` | `TURBO_TEAM` | `slug` query parameter |
258
+ | `signatureKey` | `TURBO_REMOTE_CACHE_SIGNATURE_KEY`, read only under turbo.json's `remoteCache.signature: true` (or `TURBO_SIGNATURE`), as Turbo reads it | HMAC-SHA256 key (≥ 32 bytes, used raw); a download whose tag does not verify is a miss |
259
+ | `timeoutMs` | `TURBO_REMOTE_CACHE_TIMEOUT`, then `remoteCache.timeout` (seconds) | HEAD/GET/POST deadline (default 30 s; 0 none) |
260
+ | `uploadTimeoutMs` | `TURBO_REMOTE_CACHE_UPLOAD_TIMEOUT`, then `remoteCache.uploadTimeout` (seconds) | PUT deadline (default 60 s; 0 none) |
261
+ | `retries` | — | resends of a request answered 429 / 5xx (not 501) or never connected (default 1, Turbo's); 0 turns them off |
262
+
263
+ The signature is Turbo's current scheme (`artifact-signature:v2`: prefix, hash, team id and body, each length-prefixed, under HMAC-SHA256, base64 in `x-artifact-tag`). A signed body is written to a temp file before its tag can be checked, so one past core's artifact ceiling (2 GiB, at zstd's bound) is refused as it passes it, and is a miss.
264
+
265
+ Artifacts stream both ways on both wires: an upload sends the local artifact from its file, a download hands vx the response body to write straight to disk. A signed download must verify before vx sees a byte, so it is written to a temp file in vx's cache directory (which the sandbox walls, unlike the shared temp dir a sandboxed task may read) and verified from there (the tag covers the body's length, which a chunked response does not declare up front); a bad tag deletes the temp and reads as a miss, and a good one is handed over as a stream that deletes the temp once it is read or cancelled. A process that exits first (vx's Ctrl-C exit awaits no stream) deletes it on the way out.
266
+
267
+ ## `nxCache()` — an Nx self-hosted remote cache
268
+
269
+ Store vx artifacts in any server implementing Nx's remote cache OpenAPI spec (`GET`/`PUT /v1/cache/{hash}`, Bearer auth, immutable records — a second write of a hash is `409`, which the plugin treats as done). Same rule: the wire is theirs, the bytes are vx's. A download asks for `Accept: application/octet-stream`, as Nx's own client does: an API gateway that keys binary media on `Accept` base64-encodes anything else (nx#33092); `turboCache()` asks the same way.
270
+
271
+ ```ts
272
+ import { defineWorkspace } from '@vzn/vx/config'
273
+ import { nxCache } from '@vzn/vx-migrate'
274
+
275
+ export default defineWorkspace({
276
+ plugins: [nxCache({ server: 'https://cache.example.com', accessToken: process.env.CACHE_TOKEN })],
277
+ })
278
+ ```
279
+
280
+ Every option falls back to the tool's own environment variable; with nothing configured the plugin **declines** and the run stays local.
281
+
282
+ | Option | Environment variable | Meaning |
283
+ | ------------- | ------------------------------------------ | ------------------------------------------------------------- |
284
+ | `server` | `NX_SELF_HOSTED_REMOTE_CACHE_SERVER` | base URL of the cache server; a `user:pass@` in it is refused |
285
+ | `accessToken` | `NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN` | Bearer token; omit for a server that runs open |
286
+ | `timeoutMs` | — | per-request deadline (default 30 s; a positive number of ms) |
287
+ | `retries` | — | resends, as `turboCache()`'s (default 1) |
288
+
289
+ The Nx spec has no existence probe, so `has` (the `--dry` prediction; the prefetch pass calls `get`) is a `GET` whose body is cancelled before it answers. The wire carries no producing-task duration, so a remote hit reports none.
290
+
291
+ ## Remote-cache behaviour (both)
292
+
293
+ - 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.
294
+ - 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).
295
+ - 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.
296
+ - A refused token (`401`/`403`) warns **once** and turns the layer off for the rest of the process (for `nxCache()`, a `403` on an upload is the spec's read-only token: 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).
297
+ - Policy (`--cache=remote:r`, …) is enforced by core's `LayeredCache`, which the plugins wrap — a read-only token pairs naturally with `remote:r`.
298
+ - 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.
299
+
300
+ ## Testing
301
+
302
+ `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).
303
+
304
+ ## History
305
+
306
+ `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.
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Root entry shim: Bun 1.4.0's `--compile` binaries resolve an on-disk
2
+ // package by `<pkg>/index.ts` and ignore package.json `exports` / `main`
3
+ // (see packages/vx/index.ts). Same module as the exports map names.
4
+ export * from './src/index.js'
package/package.json CHANGED
@@ -1,13 +1,51 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.0",
4
- "description": "Placeholder; real releases are published by CI.",
3
+ "version": "0.0.484",
5
4
  "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/vznjs/vx.git"
5
+ "description": "Adopt @vzn/vx from Turborepo or Nx: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
6
+ "keywords": [
7
+ "vx",
8
+ "vx-plugin",
9
+ "monorepo",
10
+ "turborepo",
11
+ "nx",
12
+ "migration",
13
+ "remote-cache"
14
+ ],
15
+ "type": "module",
16
+ "main": "./src/index.ts",
17
+ "types": "./src/index.ts",
18
+ "bin": {
19
+ "vx-migrate": "./src/bin.ts",
20
+ "nx-exec": "./src/nx-exec.cjs",
21
+ "nx-env": "./src/nx-env.cjs"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./src/index.ts",
26
+ "import": "./src/index.ts"
27
+ }
28
+ },
29
+ "files": [
30
+ "index.ts",
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "bun": ">=1.4"
9
37
  },
10
38
  "publishConfig": {
11
39
  "access": "public"
12
- }
40
+ },
41
+ "peerDependencies": {
42
+ "@vzn/vx": "^0.0.484"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/vznjs/vx.git",
47
+ "directory": "packages/vx-migrate"
48
+ },
49
+ "homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-migrate#readme",
50
+ "bugs": "https://github.com/vznjs/vx/issues"
13
51
  }
@@ -0,0 +1,125 @@
1
+ // The one shape both adoption plugins have: a `project`-stage plugin over a
2
+ // mapping of the whole workspace, computed once per RUN on the first
3
+ // package the stage visits and shared by the rest (turbo.json and the Nx
4
+ // graph are one source for every package, and a `dependsOn` is only valid
5
+ // against all of them at once). `turbo()` and `nx()` each carried this
6
+ // skeleton until item 592; what differs is only how the mapping is made.
7
+
8
+ import { definePlugin, type ProjectHookContext, type TaskConfig, type VxPlugin } from '@vzn/vx'
9
+ import { type AdoptionMapping, cachedMapping } from './mapping-cache.js'
10
+ import { warnGaps } from './plugin-gaps.js'
11
+ import { headStamp, spareTrackedOutputs, trackedFiles } from './tracked-outputs.js'
12
+
13
+ async function spare(
14
+ ctx: ProjectHookContext,
15
+ mapping: AdoptionMapping,
16
+ tracked: readonly string[] | null,
17
+ ): Promise<AdoptionMapping> {
18
+ if (tracked === null) return mapping
19
+ const dirs = new Map(ctx.projects.map((p) => [p.name, p.dir]))
20
+ const projects = [...mapping.byName].flatMap(([name, p]) => {
21
+ const dir = dirs.get(name)
22
+ return dir === undefined ? [] : [{ name, dir, tasks: p.tasks }]
23
+ })
24
+ const added = spareTrackedOutputs(ctx.workspaceRoot, projects, tracked)
25
+ if (added.length === 0) return mapping
26
+ const todos = new Map([...mapping.gaps.todos].map(([k, v]) => [k, [...v]]))
27
+ for (const [id, todo] of added) {
28
+ let ids = todos.get(todo)
29
+ if (ids === undefined) todos.set(todo, (ids = []))
30
+ ids.push(id)
31
+ }
32
+ return { byName: mapping.byName, gaps: { notes: mapping.gaps.notes, todos } }
33
+ }
34
+
35
+ /**
36
+ * What a plugin hands the skeleton for one run: every input its mapping
37
+ * reads, as text (the cache key, `mapping-cache.ts`), and the mapping
38
+ * itself, made only when the key has none kept.
39
+ */
40
+ export interface AdoptionRun {
41
+ /** The kept mapping's file name: `nx`, `turbo`. */
42
+ readonly name: string
43
+ readonly reads: readonly string[]
44
+ /** `tracked` is the workspace's `git ls-files`, read once a mapping (null without git). */
45
+ readonly map: (tracked: () => Promise<readonly string[] | null>) => Promise<AdoptionMapping>
46
+ /**
47
+ * The adopted tool never cleans an output (Turbo, Nx), so a committed
48
+ * file under one is taken back (`tracked-outputs.ts`).
49
+ */
50
+ readonly spareTracked?: boolean
51
+ }
52
+
53
+ /**
54
+ * `mapRun` sees the first visit's context: the workspace root, the cache
55
+ * dir and every package core discovered (`ctx.projects` — walking the
56
+ * workspace again cost a 1,000-package run a second discovery, 2026-09-10).
57
+ */
58
+ export function adoptionPlugin(
59
+ meta: ImportMeta,
60
+ mapRun: (ctx: ProjectHookContext) => Promise<AdoptionRun>,
61
+ reads: readonly string[] = [],
62
+ // `config`: a workspace-wide key of the adopted tool's (nx.json `parallel`,
63
+ // Turbo's `cacheMaxSize`) that the workspace itself leaves unset.
64
+ // `discover`: a project the tool has that no member glob lists.
65
+ stages: Pick<VxPlugin, 'config' | 'discover'> = {},
66
+ ): VxPlugin {
67
+ // One mapping per RUN, not per process: the workspace module — and so
68
+ // this plugin instance — outlives a run under `vx watch`, and a mapping
69
+ // memoized for the process ran the cycle after a package.json script
70
+ // edit on the old command (2026-09-10). `ctx.projects` is one array per
71
+ // run, so its identity is the run's.
72
+ let mappedFor: ProjectHookContext['projects'] | undefined
73
+ let mapping: Promise<AdoptionMapping> | undefined
74
+ let warned = false
75
+ const plugin = definePlugin(meta, {
76
+ async project(config, ctx) {
77
+ if (mappedFor !== ctx.projects) {
78
+ mappedFor = ctx.projects
79
+ mapping = mapRun(ctx).then(async (r) =>
80
+ r.spareTracked === true
81
+ ? cachedMapping(
82
+ ctx.cacheDir,
83
+ r.name,
84
+ [...r.reads, await headStamp(ctx.workspaceRoot)],
85
+ async () => {
86
+ let read: Promise<readonly string[] | null> | undefined
87
+ const tracked = () => (read ??= trackedFiles(ctx.workspaceRoot))
88
+ return spare(ctx, await r.map(tracked), await tracked())
89
+ },
90
+ )
91
+ : cachedMapping(ctx.cacheDir, r.name, r.reads, () =>
92
+ r.map(() => trackedFiles(ctx.workspaceRoot)),
93
+ ),
94
+ )
95
+ warned = false
96
+ }
97
+ const mapped = await mapping!
98
+ if (!warned) {
99
+ warned = true
100
+ warnGaps(ctx.warn, plugin.name, mapped.gaps)
101
+ }
102
+ const project = mapped.byName.get(ctx.name)
103
+ if (project === undefined) return
104
+ config.tasks ??= {}
105
+ for (const t of project.tasks) {
106
+ if (t.task === null) continue
107
+ // The user's own declaration wins — the plugin fills, never overwrites.
108
+ // The mapping's own object, not a copy: the mapping is made per RUN
109
+ // (above), so nothing outlives the run that fills from it, and core
110
+ // only reads a stage-given task after validating it. The copy was
111
+ // the per-process memo's guard and cost 10 ms per run at 1,000
112
+ // projects, 3,000 clones (item 609).
113
+ config.tasks[t.name] ??= t.task as unknown as TaskConfig
114
+ }
115
+ },
116
+ // The root files the mapping reads (`turbo.json`, `nx.json`) shape every
117
+ // task and belong to no project: claimed, so `--affected` selects every
118
+ // project on an edit and `vx watch` re-runs on one (item 961). Which
119
+ // tasks an edit moved would take mapping both sides; every project is
120
+ // the answer a claimant gives when it cannot tell.
121
+ ...(reads.length > 0 ? { fingerprint: { files: reads, affected: () => undefined } } : {}),
122
+ ...stages,
123
+ })
124
+ return plugin
125
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env -S bun --no-env-file --no-install
2
+ // `bunx @vzn/vx-migrate` — the adoption tool's own entry. Core's error
3
+ // classes are honoured the way `vx` honours them: a UserError is one line
4
+ // on stderr, anything else is a crash worth a stack.
5
+
6
+ import { isUserError } from '@vzn/vx'
7
+ import { migrateCmd } from './migrate.js'
8
+
9
+ try {
10
+ process.exitCode = await migrateCmd(process.argv.slice(2))
11
+ } catch (err) {
12
+ if (isUserError(err)) {
13
+ process.stderr.write(`vx-migrate: ${err.message}\n`)
14
+ process.exitCode = 1
15
+ } else {
16
+ throw err
17
+ }
18
+ }